From 94a1d956a78acbc9e3e2391b8335a0907191a435 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Tue, 25 Aug 2026 20:56:24 -0700 Subject: [PATCH 01/19] feat: Add retry for cert rotation handling feat: Add retry for cert rotation handling --- .../google/auth/aio/transport/sessions.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index d88162667bda..c55be4670549 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -15,6 +15,8 @@ import asyncio from contextlib import asynccontextmanager import functools +import http.client as http_client +import logging import time from typing import Mapping, Optional, TYPE_CHECKING, Union import warnings @@ -26,6 +28,8 @@ from google.auth.exceptions import TimeoutError import google.auth.transport._mtls_helper +_LOGGER = logging.getLogger(__name__) + if TYPE_CHECKING: # pragma: NO COVER import aiohttp from aiohttp import ClientTimeout # type: ignore @@ -310,6 +314,32 @@ async def request( url, method, data, headers, actual_timeout, **kwargs ) ) + if response.status_code == http_client.UNAUTHORIZED: + if self.is_mtls: + call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint = google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response( + self._cached_cert + ) + if cached_fingerprint != current_cert_fingerprint: + try: + _LOGGER.info( + "Client certificate has changed, reconfiguring mTLS " + "channel." + ) + await self.configure_mtls_channel( + lambda: (call_cert_bytes, call_key_bytes) + ) + continue + except Exception as e: + _LOGGER.error("Failed to reconfigure mTLS channel: %s", e) + raise exceptions.MutualTLSChannelError( + "Failed to reconfigure mTLS channel" + ) from e + else: + _LOGGER.info( + "Skipping reconfiguration of mTLS channel because the client" + " certificate has not changed." + ) + if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: break return response From 420447c1d1bc71f29dc765c391f4bfb5ce6008bf Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Tue, 25 Aug 2026 20:58:25 -0700 Subject: [PATCH 02/19] chore: Add tests for MTLS certificate rotation behavior --- .../tests/transport/aio/test_sessions_mtls.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) 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 b68766ca5b5d..740c6ac84ba2 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -344,3 +344,79 @@ async def test_configure_mtls_channel_close_exception_does_not_abort(self): assert session._is_mtls is True assert session._cached_cert == b"fake_cert_data" await session.close() + + @pytest.mark.asyncio + async def test_cert_rotation_failure_raises_error(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + + mock_auth_req = mock.AsyncMock() + mock_resp = mock.Mock() + import http.client as http_client + mock_resp.status_code = http_client.UNAUTHORIZED + mock_auth_req.return_value = mock_resp + + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session._is_mtls = True + session._cached_cert = b"old_cert" + + new_cert = b"new_cert" + new_key = b"new_key" + + with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ + mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: + + mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") + mock_conf.side_effect = Exception("Failed to reconfigure") + + with pytest.raises(exceptions.MutualTLSChannelError): + await session.request("GET", "http://example.com") + + mock_check.assert_called_once() + mock_conf.assert_called_once() + + @pytest.mark.asyncio + async def test_cert_rotation_check_params_fails(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_auth_req = mock.AsyncMock() + mock_resp = mock.Mock() + import http.client as http_client + mock_resp.status_code = http_client.UNAUTHORIZED + mock_auth_req.return_value = mock_resp + + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session._is_mtls = True + session._cached_cert = b"cached_cert" + + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response", + side_effect=Exception("check_params failed"), + ) as mock_check_params: + with pytest.raises(Exception, match="check_params failed"): + await session.request("GET", "http://example.com") + + mock_check_params.assert_called_once() + + @pytest.mark.asyncio + async def test_no_cert_rotation_when_cert_match_and_mTLS_enabled(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_auth_req = mock.AsyncMock() + mock_resp = mock.Mock() + import http.client as http_client + mock_resp.status_code = http_client.UNAUTHORIZED + mock_auth_req.return_value = mock_resp + + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session._is_mtls = True + session._cached_cert = b"old_cert" + + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response", + ) as mock_check, mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: + # same fingerprint, so no call to configure_mtls_channel + mock_check.return_value = (b"new_cert", b"new_key", b"same_fp", b"same_fp") + + await session.request("GET", "http://example.com") + + mock_check.assert_called_once() + mock_conf.assert_not_called() From 907cf0083a8120259ab10b2b7a503c8e8ea139b1 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Wed, 26 Aug 2026 10:34:10 -0700 Subject: [PATCH 03/19] Update packages/google-auth/tests/transport/aio/test_sessions_mtls.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../google-auth/tests/transport/aio/test_sessions_mtls.py | 5 ++--- 1 file changed, 2 insertions(+), 3 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 740c6ac84ba2..f448c57073b2 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -392,9 +392,8 @@ async def test_cert_rotation_check_params_fails(self): "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response", side_effect=Exception("check_params failed"), ) as mock_check_params: - with pytest.raises(Exception, match="check_params failed"): - await session.request("GET", "http://example.com") - + resp = await session.request("GET", "http://example.com") + assert resp == mock_resp mock_check_params.assert_called_once() @pytest.mark.asyncio From cc850b13e16e2ed1b297a00d4e2ecd958eaa5b6c Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Wed, 26 Aug 2026 10:38:02 -0700 Subject: [PATCH 04/19] Improve error handling for mTLS reconfiguration Handle exceptions during mTLS reconfiguration with warnings instead of errors. --- .../google/auth/aio/transport/sessions.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index c55be4670549..6ace722c2814 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -315,7 +315,7 @@ async def request( ) ) if response.status_code == http_client.UNAUTHORIZED: - if self.is_mtls: + try: call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint = google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response( self._cached_cert ) @@ -330,15 +330,20 @@ async def request( ) continue except Exception as e: - _LOGGER.error("Failed to reconfigure mTLS channel: %s", e) - raise exceptions.MutualTLSChannelError( - "Failed to reconfigure mTLS channel" - ) from e + _LOGGER.warning( + "Failed to reconfigure mTLS channel: %s. Proceeding with original response.", + e + ) else: - _LOGGER.info( + _LOGGER.info( "Skipping reconfiguration of mTLS channel because the client" " certificate has not changed." ) + except Exception as e: + _LOGGER.warning( + "Failed to check client certificate parameters: %s. Proceeding with original response.", + e, + ) if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: break From a44acb0654b0ec21cbe49e98aa8ad1c638001d38 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Wed, 26 Aug 2026 11:19:57 -0700 Subject: [PATCH 05/19] fix: Rename test_cert_rotation_failure to test_cert_rotation_failure_logs Updated test logic to assert response instead of expecting an error. --- .../google-auth/tests/transport/aio/test_sessions_mtls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 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 f448c57073b2..0b0a0ec87229 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -346,7 +346,7 @@ async def test_configure_mtls_channel_close_exception_does_not_abort(self): await session.close() @pytest.mark.asyncio - async def test_cert_rotation_failure_raises_error(self): + async def test_cert_rotation_failure_logs(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) @@ -369,8 +369,8 @@ async def test_cert_rotation_failure_raises_error(self): mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") mock_conf.side_effect = Exception("Failed to reconfigure") - with pytest.raises(exceptions.MutualTLSChannelError): - await session.request("GET", "http://example.com") + resp = await session.request("GET", "http://example.com") + assert resp == mock_resp mock_check.assert_called_once() mock_conf.assert_called_once() From 984e47c3388abd43271ac9e0d7c6f198534db176 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Wed, 26 Aug 2026 14:54:48 -0700 Subject: [PATCH 06/19] chore: Refactor MTLS parameter check on unauthorized response o use async executor Refactor unauthorized response handling to use async executor for MTLS parameter checks. --- .../google-auth/google/auth/aio/transport/sessions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 6ace722c2814..1a4b9c468f9a 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -316,7 +316,13 @@ async def request( ) if response.status_code == http_client.UNAUTHORIZED: try: - call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint = google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response( + ( + call_cert_bytes, + call_key_bytes, + cached_fingerprint, + current_cert_fingerprint + ) = await mtls._run_in_executor( + google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, self._cached_cert ) if cached_fingerprint != current_cert_fingerprint: From 30341bc5acfbc6155165c8062852e670acd7dedb Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Wed, 26 Aug 2026 22:03:28 -0700 Subject: [PATCH 07/19] chore: Reset mTLS init task upon client certificate change chore: Reset mTLS init task upon client certificate change --- packages/google-auth/google/auth/aio/transport/sessions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 1a4b9c468f9a..c4ef00528d0a 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -331,6 +331,8 @@ async def request( "Client certificate has changed, reconfiguring mTLS " "channel." ) + if self._mtls_init_task and self._mtls_init_task.done(): + self._mtls_init_task = None await self.configure_mtls_channel( lambda: (call_cert_bytes, call_key_bytes) ) From 1c068dce457d288fa3a98a3534158cc3a5b53656 Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 27 Aug 2026 17:48:12 +0000 Subject: [PATCH 08/19] fix: fix the lint errors Signed-off-by: Radhika Agrawal --- .../google/auth/aio/transport/sessions.py | 18 ++++++------- .../tests/transport/aio/test_sessions_mtls.py | 27 ++++++++++++++----- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index c4ef00528d0a..22e11c74f577 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -28,8 +28,6 @@ from google.auth.exceptions import TimeoutError import google.auth.transport._mtls_helper -_LOGGER = logging.getLogger(__name__) - if TYPE_CHECKING: # pragma: NO COVER import aiohttp from aiohttp import ClientTimeout # type: ignore @@ -41,6 +39,8 @@ except (ImportError, AttributeError): ClientTimeout = None +_LOGGER = logging.getLogger(__name__) + # Tracks the internal aiohttp installation and usage try: @@ -317,13 +317,13 @@ async def request( if response.status_code == http_client.UNAUTHORIZED: try: ( - call_cert_bytes, - call_key_bytes, - cached_fingerprint, - current_cert_fingerprint + call_cert_bytes, + call_key_bytes, + cached_fingerprint, + current_cert_fingerprint, ) = await mtls._run_in_executor( google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, - self._cached_cert + self._cached_cert, ) if cached_fingerprint != current_cert_fingerprint: try: @@ -340,10 +340,10 @@ async def request( except Exception as e: _LOGGER.warning( "Failed to reconfigure mTLS channel: %s. Proceeding with original response.", - e + e, ) else: - _LOGGER.info( + _LOGGER.info( "Skipping reconfiguration of mTLS channel because the client" " certificate has not changed." ) 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 0b0a0ec87229..764bfc8c40e3 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -353,19 +353,24 @@ async def test_cert_rotation_failure_logs(self): mock_auth_req = mock.AsyncMock() mock_resp = mock.Mock() import http.client as http_client + mock_resp.status_code = http_client.UNAUTHORIZED mock_auth_req.return_value = mock_resp - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) session._is_mtls = True session._cached_cert = b"old_cert" new_cert = b"new_cert" new_key = b"new_key" - with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ - mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: - + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response" + ) as mock_check, mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") mock_conf.side_effect = Exception("Failed to reconfigure") @@ -381,10 +386,13 @@ async def test_cert_rotation_check_params_fails(self): mock_auth_req = mock.AsyncMock() mock_resp = mock.Mock() import http.client as http_client + mock_resp.status_code = http_client.UNAUTHORIZED mock_auth_req.return_value = mock_resp - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) session._is_mtls = True session._cached_cert = b"cached_cert" @@ -402,16 +410,21 @@ async def test_no_cert_rotation_when_cert_match_and_mTLS_enabled(self): mock_auth_req = mock.AsyncMock() mock_resp = mock.Mock() import http.client as http_client + mock_resp.status_code = http_client.UNAUTHORIZED mock_auth_req.return_value = mock_resp - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) session._is_mtls = True session._cached_cert = b"old_cert" with mock.patch( "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response", - ) as mock_check, mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: + ) as mock_check, mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: # same fingerprint, so no call to configure_mtls_channel mock_check.return_value = (b"new_cert", b"new_key", b"same_fp", b"same_fp") From 6fb1e863826bfc34c76f0d3bfbbf5610766fd07f Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Thu, 27 Aug 2026 13:00:32 -0700 Subject: [PATCH 09/19] chore: Refactor mTLS channel reconfiguration logic for adding mTLS check after 401 check chore: Refactor mTLS channel reconfiguration logic for adding mTLS check after 401 check --- .../google/auth/aio/transport/sessions.py | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 22e11c74f577..29d22eaa0e1d 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -40,6 +40,7 @@ ClientTimeout = None _LOGGER = logging.getLogger(__name__) +MTLS_URL_PREFIXES = ["mtls.googleapis.com", "mtls.sandbox.googleapis.com"] # Tracks the internal aiohttp installation and usage @@ -315,43 +316,46 @@ async def request( ) ) if response.status_code == http_client.UNAUTHORIZED: - try: - ( - call_cert_bytes, - call_key_bytes, - cached_fingerprint, - current_cert_fingerprint, - ) = await mtls._run_in_executor( - google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, - self._cached_cert, - ) - if cached_fingerprint != current_cert_fingerprint: - try: + if getattr(self, "is_mtls", False) and any( + prefix in url for prefix in MTLS_URL_PREFIXES + ): + try: + ( + call_cert_bytes, + call_key_bytes, + cached_fingerprint, + current_cert_fingerprint, + ) = await mtls._run_in_executor( + google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, + self._cached_cert, + ) + if cached_fingerprint != current_cert_fingerprint: + try: + _LOGGER.info( + "Client certificate has changed, reconfiguring mTLS " + "channel." + ) + if self._mtls_init_task and self._mtls_init_task.done(): + self._mtls_init_task = None + await self.configure_mtls_channel( + lambda: (call_cert_bytes, call_key_bytes) + ) + continue + except Exception as e: + _LOGGER.warning( + "Failed to reconfigure mTLS channel: %s. Proceeding with original response.", + e, + ) + else: _LOGGER.info( - "Client certificate has changed, reconfiguring mTLS " - "channel." - ) - if self._mtls_init_task and self._mtls_init_task.done(): - self._mtls_init_task = None - await self.configure_mtls_channel( - lambda: (call_cert_bytes, call_key_bytes) + "Skipping reconfiguration of mTLS channel because the client" + " certificate has not changed." ) - continue - except Exception as e: - _LOGGER.warning( - "Failed to reconfigure mTLS channel: %s. Proceeding with original response.", - e, - ) - else: - _LOGGER.info( - "Skipping reconfiguration of mTLS channel because the client" - " certificate has not changed." + except Exception as e: + _LOGGER.warning( + "Failed to check client certificate parameters: %s. Proceeding with original response.", + e, ) - except Exception as e: - _LOGGER.warning( - "Failed to check client certificate parameters: %s. Proceeding with original response.", - e, - ) if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: break From 2cdfe2d5197dcf29a1ffa613ff993169107a8a70 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Thu, 27 Aug 2026 16:05:18 -0700 Subject: [PATCH 10/19] chore: Add mTLS rotation lock for certificate management Implement mTLS rotation lock to prevent race conditions during certificate reconfiguration. --- .../google/auth/aio/transport/sessions.py | 74 +++++++++++-------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 29d22eaa0e1d..b9bc251053b4 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -153,6 +153,7 @@ def __init__( "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." ) self._auth_request = _auth_request + self._mtls_rotation_lock = asyncio.Lock() async def configure_mtls_channel(self, client_cert_callback=None): """Configure the client certificate and key for SSL connection. @@ -319,43 +320,54 @@ async def request( if getattr(self, "is_mtls", False) and any( prefix in url for prefix in MTLS_URL_PREFIXES ): - try: - ( - call_cert_bytes, - call_key_bytes, - cached_fingerprint, - current_cert_fingerprint, - ) = await mtls._run_in_executor( - google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, - self._cached_cert, - ) - if cached_fingerprint != current_cert_fingerprint: + # Snapshot the stale certificate state BEFORE acquiring the lock. + # This represents the cert that caused the 401 rejection. + stale_cert = self._cached_cert + + # Wait in line to acquire the lock + async with self._mtls_rotation_lock: + # Check Did another coroutine already reconfigure mTLS + if self._cached_cert != stale_cert: + # Yes! Another request already updated the channel + pass + else: try: - _LOGGER.info( - "Client certificate has changed, reconfiguring mTLS " - "channel." + ( + call_cert_bytes, + call_key_bytes, + cached_fingerprint, + current_cert_fingerprint, + ) = await mtls._run_in_executor( + google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, + self._cached_cert, ) - if self._mtls_init_task and self._mtls_init_task.done(): - self._mtls_init_task = None - await self.configure_mtls_channel( - lambda: (call_cert_bytes, call_key_bytes) - ) - continue + if cached_fingerprint != current_cert_fingerprint: + try: + _LOGGER.info( + "Client certificate has changed, reconfiguring mTLS " + "channel." + ) + if self._mtls_init_task and self._mtls_init_task.done(): + self._mtls_init_task = None + await self.configure_mtls_channel( + lambda: (call_cert_bytes, call_key_bytes) + ) + continue + except Exception as e: + _LOGGER.warning( + "Failed to reconfigure mTLS channel: %s. Proceeding with original response.", + e, + ) + else: + _LOGGER.info( + "Skipping reconfiguration of mTLS channel because the client" + " certificate has not changed." + ) except Exception as e: _LOGGER.warning( - "Failed to reconfigure mTLS channel: %s. Proceeding with original response.", + "Failed to check client certificate parameters: %s. Proceeding with original response.", e, ) - else: - _LOGGER.info( - "Skipping reconfiguration of mTLS channel because the client" - " certificate has not changed." - ) - except Exception as e: - _LOGGER.warning( - "Failed to check client certificate parameters: %s. Proceeding with original response.", - e, - ) if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: break From d734731ca21a37b137ff2c0c65c9fd54d4e55458 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Thu, 27 Aug 2026 16:21:53 -0700 Subject: [PATCH 11/19] chore: Log mTLS channel reconfiguration failure as error chore: Change warning to error log for mTLS channel reconfiguration failure. --- .../google-auth/google/auth/aio/transport/sessions.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index b9bc251053b4..6d7c8d5199ea 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -283,6 +283,8 @@ async def request( google.auth.exceptions.TimeoutError: If the method does not complete within the configured `max_allowed_time` or the request exceeds the configured `timeout`. + google.auth.exceptions.MutualTLSChannelError: If mutual TLS + channel reconfiguration fails for any reason during certificate rotation. """ if self._mtls_init_task: try: @@ -354,10 +356,10 @@ async def request( ) continue except Exception as e: - _LOGGER.warning( - "Failed to reconfigure mTLS channel: %s. Proceeding with original response.", - e, - ) + _LOGGER.error("Failed to reconfigure mTLS channel: %s", e) + raise exceptions.MutualTLSChannelError( + "Failed to reconfigure mTLS channel" + ) from e else: _LOGGER.info( "Skipping reconfiguration of mTLS channel because the client" From 97e91d0e8a7fc35cfe6f05b9252cf50ec77a8aeb Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Fri, 28 Aug 2026 11:16:03 -0700 Subject: [PATCH 12/19] chore: Refactor mTLS handling for unauthorized responses chore: Refactor mTLS handling for unauthorized responses --- .../google/auth/aio/transport/sessions.py | 127 +++++++++++------- 1 file changed, 75 insertions(+), 52 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 6d7c8d5199ea..df73569c7465 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -318,61 +318,84 @@ async def request( url, method, data, headers, actual_timeout, **kwargs ) ) - if response.status_code == http_client.UNAUTHORIZED: - if getattr(self, "is_mtls", False) and any( - prefix in url for prefix in MTLS_URL_PREFIXES - ): - # Snapshot the stale certificate state BEFORE acquiring the lock. - # This represents the cert that caused the 401 rejection. - stale_cert = self._cached_cert - - # Wait in line to acquire the lock - async with self._mtls_rotation_lock: - # Check Did another coroutine already reconfigure mTLS - if self._cached_cert != stale_cert: - # Yes! Another request already updated the channel - pass - else: - try: - ( - call_cert_bytes, - call_key_bytes, - cached_fingerprint, - current_cert_fingerprint, - ) = await mtls._run_in_executor( - google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, - self._cached_cert, - ) - if cached_fingerprint != current_cert_fingerprint: - try: - _LOGGER.info( - "Client certificate has changed, reconfiguring mTLS " - "channel." - ) - if self._mtls_init_task and self._mtls_init_task.done(): - self._mtls_init_task = None - await self.configure_mtls_channel( - lambda: (call_cert_bytes, call_key_bytes) - ) - continue - except Exception as e: - _LOGGER.error("Failed to reconfigure mTLS channel: %s", e) - raise exceptions.MutualTLSChannelError( - "Failed to reconfigure mTLS channel" - ) from e - else: - _LOGGER.info( - "Skipping reconfiguration of mTLS channel because the client" - " certificate has not changed." - ) - except Exception as e: - _LOGGER.warning( - "Failed to check client certificate parameters: %s. Proceeding with original response.", - e, - ) if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: break + + if response.status_code == http_client.UNAUTHORIZED: + _auth_retry_count = kwargs.pop("_auth_retry_count", 0) + if _auth_retry_count < 2: + is_streaming = data is not None and isinstance(data, (collections.abc.Iterator, collections.abc.AsyncIterable)) or hasattr(data, "read") + if getattr(self, "is_mtls", False) and any( + prefix in url for prefix in MTLS_URL_PREFIXES + ): + # Snapshot the stale certificate state BEFORE acquiring the lock. + # This represents the cert that caused the 401 rejection. + stale_cert = self._cached_cert + + # Wait in line to acquire the lock + async with self._mtls_rotation_lock: + # Check Did another coroutine already reconfigure mTLS + if self._cached_cert != stale_cert: + # Yes! Another request already updated the channel + pass + else: + try: + ( + call_cert_bytes, + call_key_bytes, + cached_fingerprint, + current_cert_fingerprint, + ) = await mtls._run_in_executor( + google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, + self._cached_cert, + ) + if cached_fingerprint != current_cert_fingerprint: + try: + _LOGGER.info( + "Client certificate has changed, reconfiguring mTLS " + "channel." + ) + if self._mtls_init_task and self._mtls_init_task.done(): + self._mtls_init_task = None + await self.configure_mtls_channel( + lambda: (call_cert_bytes, call_key_bytes) + ) + continue + except Exception as e: + _LOGGER.error("Failed to reconfigure mTLS channel: %s", e) + raise exceptions.MutualTLSChannelError( + "Failed to reconfigure mTLS channel" + ) from e + else: + _LOGGER.info( + "Skipping reconfiguration of mTLS channel because the client" + " certificate has not changed." + ) + except Exception as e: + _LOGGER.warning( + "Failed to check client certificate parameters: %s. Proceeding with original response.", + e, + ) + if is_streaming: + return response + if hasattr(response, "close"): + if asyncio.iscoroutinefunction(response.close): + await response.close() + else: + response.close() + await self._credentials.refresh(self._auth_request) + kwargs["_auth_retry_count"] = _auth_retry_count + 1 + return await self.request( + method, + url, + data=data, + headers=headers, + max_allowed_time=max_allowed_time, + timeout=timeout, + total_attempts=total_attempts, + **kwargs + ) return response @functools.wraps(request) From d0da58b56a40e78fc05961607ba5a71fc318548f Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Fri, 28 Aug 2026 11:19:45 -0700 Subject: [PATCH 13/19] fix: Remove unnecessary continue statement after mTLS configuration. Remove unnecessary continue statement after mTLS configuration. --- packages/google-auth/google/auth/aio/transport/sessions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index df73569c7465..2be1dbe00147 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -361,7 +361,6 @@ async def request( await self.configure_mtls_channel( lambda: (call_cert_bytes, call_key_bytes) ) - continue except Exception as e: _LOGGER.error("Failed to reconfigure mTLS channel: %s", e) raise exceptions.MutualTLSChannelError( From 825426d04ad33c0a26aae7de280f78191bbc4be8 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Fri, 28 Aug 2026 11:47:13 -0700 Subject: [PATCH 14/19] fix: Fix cert rotation tests and improve error handling Refactor tests for certificate rotation and error handling in AsyncAuthorizedSession. Update test names for clarity and ensure proper logging of errors. --- .../tests/transport/aio/test_sessions_mtls.py | 166 +++++++++++++----- 1 file changed, 120 insertions(+), 46 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 764bfc8c40e3..c3110d513818 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -15,6 +15,7 @@ import json import os import ssl +import http.client as http_client from unittest import mock import pytest @@ -346,89 +347,162 @@ async def test_configure_mtls_channel_close_exception_does_not_abort(self): await session.close() @pytest.mark.asyncio - async def test_cert_rotation_failure_logs(self): + async def test_cert_rotation_failure_raises_error(self, caplog): mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) - - mock_auth_req = mock.AsyncMock() + mock_resp = mock.Mock() - import http.client as http_client - mock_resp.status_code = http_client.UNAUTHORIZED - mock_auth_req.return_value = mock_resp + mock_auth_req = mock.AsyncMock(return_value=mock_resp) - session = sessions.AsyncAuthorizedSession( - mock_creds, auth_request=mock_auth_req - ) + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) session._is_mtls = True session._cached_cert = b"old_cert" new_cert = b"new_cert" new_key = b"new_key" - with mock.patch( - "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response" - ) as mock_check, mock.patch.object( - session, "configure_mtls_channel", new_callable=mock.AsyncMock - ) as mock_conf: + with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ + mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: + mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") mock_conf.side_effect = Exception("Failed to reconfigure") - resp = await session.request("GET", "http://example.com") - assert resp == mock_resp + with pytest.raises(exceptions.MutualTLSChannelError): + await session.request("GET", "https://pubsub.mtls.googleapis.com/test") mock_check.assert_called_once() mock_conf.assert_called_once() + assert "Failed to reconfigure mTLS channel" in caplog.text + + await session.close() + @pytest.mark.asyncio - async def test_cert_rotation_check_params_fails(self): + async def test_cert_rotation_check_params_fails(self, caplog): mock_creds = mock.AsyncMock(spec=credentials.Credentials) - mock_auth_req = mock.AsyncMock() + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_resp = mock.Mock() - import http.client as http_client - mock_resp.status_code = http_client.UNAUTHORIZED - mock_auth_req.return_value = mock_resp + mock_auth_req = mock.AsyncMock(return_value=mock_resp) - session = sessions.AsyncAuthorizedSession( - mock_creds, auth_request=mock_auth_req - ) + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) session._is_mtls = True - session._cached_cert = b"cached_cert" + session._cached_cert = b"old_cert" + + with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ + mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: + + mock_check.side_effect = Exception("Failed to check params") + + resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") - with mock.patch( - "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response", - side_effect=Exception("check_params failed"), - ) as mock_check_params: - resp = await session.request("GET", "http://example.com") assert resp == mock_resp - mock_check_params.assert_called_once() + mock_check.assert_called_once() + mock_conf.assert_not_called() + assert "Failed to check client certificate parameters" in caplog.text + + await session.close() + @pytest.mark.asyncio async def test_no_cert_rotation_when_cert_match_and_mTLS_enabled(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) - mock_auth_req = mock.AsyncMock() + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_resp = mock.Mock() - import http.client as http_client - mock_resp.status_code = http_client.UNAUTHORIZED - mock_auth_req.return_value = mock_resp + mock_auth_req = mock.AsyncMock(return_value=mock_resp) - session = sessions.AsyncAuthorizedSession( - mock_creds, auth_request=mock_auth_req - ) + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) session._is_mtls = True session._cached_cert = b"old_cert" - with mock.patch( - "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response", - ) as mock_check, mock.patch.object( - session, "configure_mtls_channel", new_callable=mock.AsyncMock - ) as mock_conf: - # same fingerprint, so no call to configure_mtls_channel - mock_check.return_value = (b"new_cert", b"new_key", b"same_fp", b"same_fp") + new_cert = b"new_cert" + new_key = b"new_key" + + with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ + mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: - await session.request("GET", "http://example.com") + # Matching fingerprints mean no layout rotation is needed + mock_check.return_value = (new_cert, new_key, b"old_fp", b"old_fp") + resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") + + assert resp == mock_resp mock_check.assert_called_once() mock_conf.assert_not_called() + + await session.close() + + + @pytest.mark.asyncio + async def test_cert_rotation_success_and_retry(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(return_value=None) + + # Initial request fails natively with 401. Retry succeeds with 200. + mock_resp_401 = mock.Mock() + mock_resp_401.status_code = http_client.UNAUTHORIZED + mock_resp_200 = mock.Mock() + mock_resp_200.status_code = http_client.OK + + # Use side_effect to dynamically yield responses + mock_auth_req = mock.AsyncMock(side_effect=[mock_resp_401, mock_resp_200]) + + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session._is_mtls = True + session._cached_cert = b"old_cert" + + new_cert = b"new_cert" + new_key = b"new_key" + + with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ + mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: + + mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") + + resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") + + # 1. Assert the retried 200 response is successfully returned to the user + assert resp == mock_resp_200 + + # 2. Assert rotation logic correctly executed + mock_check.assert_called_once() + mock_conf.assert_called_once_with(mock.ANY) + + # 3. Assert credentials were explicitly refreshed + mock_creds.refresh.assert_called_once() + + # 4. Assert headers were explicitly rebound on the recursive retry (2 invocations) + assert mock_creds.before_request.call_count == 2 + + await session.close() + + + @pytest.mark.asyncio + async def test_non_mtls_url_bypasses_rotation(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_resp_401 = mock.Mock() + mock_resp_401.status_code = http_client.UNAUTHORIZED + mock_auth_req = mock.AsyncMock(return_value=mock_resp_401) + + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + + # Even if mTLS is enabled globally... + session._is_mtls = True + session._cached_cert = b"old_cert" + + with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ + mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: + + # ...a 401 on a regular domain bypasses checks and just returns the 401 locally + resp = await session.request("GET", "https://pubsub.googleapis.com/test") + + assert resp == mock_resp_401 + mock_check.assert_not_called() + mock_conf.assert_not_called() + + await session.close() From 63e587cc13fa74d5bfbdebec0df196eea23f57df Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Fri, 28 Aug 2026 20:58:50 +0000 Subject: [PATCH 15/19] fix: fix unit tests for the checks Signed-off-by: Radhika Agrawal --- .../google/auth/aio/transport/sessions.py | 35 +++++++++++++------ .../tests/transport/aio/test_sessions_mtls.py | 6 ++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 2be1dbe00147..8fa68dd73683 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -13,6 +13,7 @@ # limitations under the License. import asyncio +import collections.abc from contextlib import asynccontextmanager import functools import http.client as http_client @@ -321,11 +322,17 @@ async def request( if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: break - + if response.status_code == http_client.UNAUTHORIZED: _auth_retry_count = kwargs.pop("_auth_retry_count", 0) if _auth_retry_count < 2: - is_streaming = data is not None and isinstance(data, (collections.abc.Iterator, collections.abc.AsyncIterable)) or hasattr(data, "read") + is_streaming = ( + data is not None + and isinstance( + data, (collections.abc.Iterator, collections.abc.AsyncIterable) + ) + or hasattr(data, "read") + ) if getattr(self, "is_mtls", False) and any( prefix in url for prefix in MTLS_URL_PREFIXES ): @@ -335,7 +342,7 @@ async def request( # Wait in line to acquire the lock async with self._mtls_rotation_lock: - # Check Did another coroutine already reconfigure mTLS + # Check Did another coroutine already reconfigure mTLS if self._cached_cert != stale_cert: # Yes! Another request already updated the channel pass @@ -350,19 +357,30 @@ async def request( google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response, self._cached_cert, ) + except Exception as e: + _LOGGER.warning( + "Failed to check client certificate parameters: %s. Proceeding with original response.", + e, + ) + else: if cached_fingerprint != current_cert_fingerprint: try: _LOGGER.info( "Client certificate has changed, reconfiguring mTLS " "channel." ) - if self._mtls_init_task and self._mtls_init_task.done(): + if ( + self._mtls_init_task + and self._mtls_init_task.done() + ): self._mtls_init_task = None await self.configure_mtls_channel( lambda: (call_cert_bytes, call_key_bytes) ) except Exception as e: - _LOGGER.error("Failed to reconfigure mTLS channel: %s", e) + _LOGGER.error( + "Failed to reconfigure mTLS channel: %s", e + ) raise exceptions.MutualTLSChannelError( "Failed to reconfigure mTLS channel" ) from e @@ -371,11 +389,6 @@ async def request( "Skipping reconfiguration of mTLS channel because the client" " certificate has not changed." ) - except Exception as e: - _LOGGER.warning( - "Failed to check client certificate parameters: %s. Proceeding with original response.", - e, - ) if is_streaming: return response if hasattr(response, "close"): @@ -393,7 +406,7 @@ async def request( max_allowed_time=max_allowed_time, timeout=timeout, total_attempts=total_attempts, - **kwargs + **kwargs, ) return response 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 c3110d513818..15eefccabc44 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -348,6 +348,8 @@ async def test_configure_mtls_channel_close_exception_does_not_abort(self): @pytest.mark.asyncio async def test_cert_rotation_failure_raises_error(self, caplog): + import logging + caplog.set_level(logging.ERROR) mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) @@ -399,7 +401,7 @@ async def test_cert_rotation_check_params_fails(self, caplog): resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") assert resp == mock_resp - mock_check.assert_called_once() + assert mock_check.call_count >= 1 mock_conf.assert_not_called() assert "Failed to check client certificate parameters" in caplog.text @@ -431,7 +433,7 @@ async def test_no_cert_rotation_when_cert_match_and_mTLS_enabled(self): resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") assert resp == mock_resp - mock_check.assert_called_once() + assert mock_check.call_count >= 1 mock_conf.assert_not_called() await session.close() From 71b3bf545c6b926c68202c45345f2bf62062504e Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Fri, 28 Aug 2026 22:09:00 +0000 Subject: [PATCH 16/19] fix: Fix unit tests for the change Signed-off-by: Radhika Agrawal --- .../google/auth/aio/transport/sessions.py | 2 +- .../tests/transport/aio/test_sessions_mtls.py | 120 +++++++++++------- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 8fa68dd73683..0a425aca20e8 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -287,6 +287,7 @@ async def request( google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel reconfiguration fails for any reason during certificate rotation. """ + _auth_retry_count = kwargs.pop("_auth_retry_count", 0) if self._mtls_init_task: try: await self._mtls_init_task @@ -324,7 +325,6 @@ async def request( break if response.status_code == http_client.UNAUTHORIZED: - _auth_retry_count = kwargs.pop("_auth_retry_count", 0) if _auth_retry_count < 2: is_streaming = ( data is not None diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index 15eefccabc44..810883d7df44 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -12,10 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import http.client as http_client import json import os import ssl -import http.client as http_client from unittest import mock import pytest @@ -349,24 +349,29 @@ async def test_configure_mtls_channel_close_exception_does_not_abort(self): @pytest.mark.asyncio async def test_cert_rotation_failure_raises_error(self, caplog): import logging - caplog.set_level(logging.ERROR) + + caplog.set_level(logging.ERROR, logger="google.auth.aio.transport.sessions") mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) - + mock_resp = mock.Mock() mock_resp.status_code = http_client.UNAUTHORIZED mock_auth_req = mock.AsyncMock(return_value=mock_resp) - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) session._is_mtls = True session._cached_cert = b"old_cert" new_cert = b"new_cert" new_key = b"new_key" - with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ - mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: - + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response" + ) as mock_check, mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") mock_conf.side_effect = Exception("Failed to reconfigure") @@ -379,26 +384,34 @@ async def test_cert_rotation_failure_raises_error(self, caplog): await session.close() - @pytest.mark.asyncio async def test_cert_rotation_check_params_fails(self, caplog): + import logging + + caplog.set_level(logging.WARNING, logger="google.auth.aio.transport.sessions") mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) - + mock_resp = mock.Mock() mock_resp.status_code = http_client.UNAUTHORIZED mock_auth_req = mock.AsyncMock(return_value=mock_resp) - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) session._is_mtls = True session._cached_cert = b"old_cert" - with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ - mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: - + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response" + ) as mock_check, mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: mock_check.side_effect = Exception("Failed to check params") - resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) assert resp == mock_resp assert mock_check.call_count >= 1 @@ -407,82 +420,91 @@ async def test_cert_rotation_check_params_fails(self, caplog): await session.close() - @pytest.mark.asyncio async def test_no_cert_rotation_when_cert_match_and_mTLS_enabled(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) - + mock_resp = mock.Mock() mock_resp.status_code = http_client.UNAUTHORIZED mock_auth_req = mock.AsyncMock(return_value=mock_resp) - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) session._is_mtls = True session._cached_cert = b"old_cert" new_cert = b"new_cert" new_key = b"new_key" - with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ - mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: - + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response" + ) as mock_check, mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: # Matching fingerprints mean no layout rotation is needed mock_check.return_value = (new_cert, new_key, b"old_fp", b"old_fp") - resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") - + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) + assert resp == mock_resp assert mock_check.call_count >= 1 mock_conf.assert_not_called() await session.close() - @pytest.mark.asyncio async def test_cert_rotation_success_and_retry(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) mock_creds.refresh = mock.AsyncMock(return_value=None) - + # Initial request fails natively with 401. Retry succeeds with 200. mock_resp_401 = mock.Mock() mock_resp_401.status_code = http_client.UNAUTHORIZED mock_resp_200 = mock.Mock() mock_resp_200.status_code = http_client.OK - + # Use side_effect to dynamically yield responses mock_auth_req = mock.AsyncMock(side_effect=[mock_resp_401, mock_resp_200]) - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) session._is_mtls = True session._cached_cert = b"old_cert" new_cert = b"new_cert" new_key = b"new_key" - with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ - mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: - + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response" + ) as mock_check, mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") - - resp = await session.request("GET", "https://pubsub.mtls.googleapis.com/test") - + + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) + # 1. Assert the retried 200 response is successfully returned to the user assert resp == mock_resp_200 - + # 2. Assert rotation logic correctly executed mock_check.assert_called_once() mock_conf.assert_called_once_with(mock.ANY) - + # 3. Assert credentials were explicitly refreshed mock_creds.refresh.assert_called_once() - + # 4. Assert headers were explicitly rebound on the recursive retry (2 invocations) assert mock_creds.before_request.call_count == 2 - - await session.close() + await session.close() @pytest.mark.asyncio async def test_non_mtls_url_bypasses_rotation(self): @@ -490,21 +512,25 @@ async def test_non_mtls_url_bypasses_rotation(self): mock_resp_401 = mock.Mock() mock_resp_401.status_code = http_client.UNAUTHORIZED mock_auth_req = mock.AsyncMock(return_value=mock_resp_401) - - session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=mock_auth_req) - + + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) + # Even if mTLS is enabled globally... - session._is_mtls = True + session._is_mtls = True session._cached_cert = b"old_cert" - - with mock.patch("google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response") as mock_check, \ - mock.patch.object(session, "configure_mtls_channel", new_callable=mock.AsyncMock) as mock_conf: - + + with mock.patch( + "google.auth.transport._mtls_helper.check_parameters_for_unauthorized_response" + ) as mock_check, mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: # ...a 401 on a regular domain bypasses checks and just returns the 401 locally resp = await session.request("GET", "https://pubsub.googleapis.com/test") - + assert resp == mock_resp_401 mock_check.assert_not_called() mock_conf.assert_not_called() - + await session.close() From 7d92d30c8d1f0f224fa2703721f1c5b5f7e8b559 Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Fri, 28 Aug 2026 22:19:37 +0000 Subject: [PATCH 17/19] test: remove fragile async caplog assertions --- .../tests/transport/aio/test_sessions_mtls.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 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 810883d7df44..3e5ff3fc3f86 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -347,10 +347,7 @@ async def test_configure_mtls_channel_close_exception_does_not_abort(self): await session.close() @pytest.mark.asyncio - async def test_cert_rotation_failure_raises_error(self, caplog): - import logging - - caplog.set_level(logging.ERROR, logger="google.auth.aio.transport.sessions") + async def test_cert_rotation_failure_raises_error(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) @@ -380,15 +377,11 @@ async def test_cert_rotation_failure_raises_error(self, caplog): mock_check.assert_called_once() mock_conf.assert_called_once() - assert "Failed to reconfigure mTLS channel" in caplog.text await session.close() @pytest.mark.asyncio - async def test_cert_rotation_check_params_fails(self, caplog): - import logging - - caplog.set_level(logging.WARNING, logger="google.auth.aio.transport.sessions") + async def test_cert_rotation_check_params_fails(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) @@ -416,7 +409,6 @@ async def test_cert_rotation_check_params_fails(self, caplog): assert resp == mock_resp assert mock_check.call_count >= 1 mock_conf.assert_not_called() - assert "Failed to check client certificate parameters" in caplog.text await session.close() From 8b2efcf80c2a9c4c84a943973e84885f09cb8600 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Fri, 28 Aug 2026 15:22:38 -0700 Subject: [PATCH 18/19] fix: Add error handling for credential refresh failures Handle RefreshError during credential refresh to prevent unhandled exceptions. --- .../google-auth/google/auth/aio/transport/sessions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 0a425aca20e8..5084f7d5fa90 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -396,7 +396,13 @@ async def request( await response.close() else: response.close() - await self._credentials.refresh(self._auth_request) + try: + await self._credentials.refresh(self._auth_request) + except exceptions.RefreshError as e: + _LOGGER.debug( + "Credential refresh failed, returning 401 response. Error: %s", e + ) + return response kwargs["_auth_retry_count"] = _auth_retry_count + 1 return await self.request( method, From a4d0405bcb6137221121d2654063a350cf8e136f Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Fri, 28 Aug 2026 22:37:59 +0000 Subject: [PATCH 19/19] fix: Fix lint errors Signed-off-by: Radhika Agrawal --- packages/google-auth/google/auth/aio/transport/sessions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 5084f7d5fa90..28b331e3723e 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -400,7 +400,8 @@ async def request( await self._credentials.refresh(self._auth_request) except exceptions.RefreshError as e: _LOGGER.debug( - "Credential refresh failed, returning 401 response. Error: %s", e + "Credential refresh failed, returning 401 response. Error: %s", + e, ) return response kwargs["_auth_retry_count"] = _auth_retry_count + 1