Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
94a1d95
feat: Add retry for cert rotation handling
agrawalradhika-cell Aug 26, 2026
420447c
chore: Add tests for MTLS certificate rotation behavior
agrawalradhika-cell Aug 26, 2026
907cf00
Update packages/google-auth/tests/transport/aio/test_sessions_mtls.py
agrawalradhika-cell Aug 26, 2026
cc850b1
Improve error handling for mTLS reconfiguration
agrawalradhika-cell Aug 26, 2026
a44acb0
fix: Rename test_cert_rotation_failure to test_cert_rotation_failure_…
agrawalradhika-cell Aug 26, 2026
984e47c
chore: Refactor MTLS parameter check on unauthorized response o use a…
agrawalradhika-cell Aug 26, 2026
30341bc
chore: Reset mTLS init task upon client certificate change
agrawalradhika-cell Aug 27, 2026
1c068dc
fix: fix the lint errors
agrawalradhika-cell Aug 27, 2026
6fb1e86
chore: Refactor mTLS channel reconfiguration logic for adding mTLS ch…
agrawalradhika-cell Aug 27, 2026
2cdfe2d
chore: Add mTLS rotation lock for certificate management
agrawalradhika-cell Aug 27, 2026
d734731
chore: Log mTLS channel reconfiguration failure as error
agrawalradhika-cell Aug 27, 2026
97e91d0
chore: Refactor mTLS handling for unauthorized responses
agrawalradhika-cell Aug 28, 2026
d0da58b
fix: Remove unnecessary continue statement after mTLS configuration.
agrawalradhika-cell Aug 28, 2026
825426d
fix: Fix cert rotation tests and improve error handling
agrawalradhika-cell Aug 28, 2026
63e587c
fix: fix unit tests for the checks
agrawalradhika-cell Aug 28, 2026
71b3bf5
fix: Fix unit tests for the change
agrawalradhika-cell Aug 28, 2026
7d92d30
test: remove fragile async caplog assertions
agrawalradhika-cell Aug 28, 2026
8b2efcf
fix: Add error handling for credential refresh failures
agrawalradhika-cell Aug 28, 2026
a4d0405
fix: Fix lint errors
agrawalradhika-cell Aug 28, 2026
2d52a21
chore: Refactor mTLS endpoint handling in sessions.py
agrawalradhika-cell Aug 30, 2026
2806f4f
chore: Reorder response closing logic for clarity
agrawalradhika-cell Aug 30, 2026
d5426f2
chore: Handle additional exception during credential refresh
agrawalradhika-cell Aug 30, 2026
968a9fd
fix: Modify mTLS rotation lock initialization
agrawalradhika-cell Aug 30, 2026
9d1a690
fix: Handle response closure in mTLS error handling
agrawalradhika-cell Aug 30, 2026
85d4a76
Fix: Fix improperly falling through to the credential refresh logic.
agrawalradhika-cell Aug 30, 2026
d1c6512
chore: Track and close old auth requests in sessions.py
agrawalradhika-cell Aug 30, 2026
b28caea
fix: Adjust max_allowed_time based on elapsed time
agrawalradhika-cell Aug 30, 2026
f176eed
chore: Add client_cert_callback to transport session
agrawalradhika-cell Aug 31, 2026
a436abe
chore: Enhance check_parameters_for_unauthorized_response with callback
agrawalradhika-cell Aug 31, 2026
fbee990
fix: Add test for certificate rotation lock contention
agrawalradhika-cell Aug 31, 2026
b310e27
fix: Enhance MTLS session tests with various scenarios
agrawalradhika-cell Aug 31, 2026
55f1ad4
Fix: Fix lint and unit tetsts
agrawalradhika-cell Aug 31, 2026
554a571
fix: fix unit tests for tests_sessions
agrawalradhika-cell Aug 31, 2026
eb28f81
chore: Refactor mTLS channel configuration callback
agrawalradhika-cell Aug 31, 2026
221810e
fix: Import urllib.parse instead of urllib
agrawalradhika-cell Aug 31, 2026
445c576
fix: Format mTLS channel configuration for readability
agrawalradhika-cell Aug 31, 2026
6b0edd3
fix: Fix test name for mTLS certificate matching
agrawalradhika-cell Aug 31, 2026
b6e30b0
fix: Refactor type annotations and error handling
agrawalradhika-cell Aug 31, 2026
7c32ec9
chore: Change exception type in test for MTLS session
agrawalradhika-cell Aug 31, 2026
f98c347
Fix duplicate isinstance check for data type
agrawalradhika-cell Sep 1, 2026
c97bfdf
fix: Format error message for MutualTLSChannelError
agrawalradhika-cell Sep 1, 2026
2790119
chore: Refactor mTLS handling and improve timeout logic
agrawalradhika-cell Sep 1, 2026
498bcd6
chore: Implement mTLS check counter for configuration management
agrawalradhika-cell Sep 1, 2026
242359a
fix: Implement test for cert rotation lock contention
agrawalradhika-cell Sep 1, 2026
8c867af
fix: Refactor mTLS configuration and error handling
agrawalradhika-cell Sep 1, 2026
8942a12
fix: Fix indentation for asyncio test decorator
agrawalradhika-cell Sep 1, 2026
eb95f3c
fix: Enhance tests for MTLS session certificate rotation
agrawalradhika-cell Sep 1, 2026
50ba485
fix: Remove stale_cert assignment in sessions.py
agrawalradhika-cell Sep 1, 2026
0e8950f
fix: Update sessions.py for lint
agrawalradhika-cell Sep 1, 2026
bc8bebb
fix: fix lint errors in sessions.py
agrawalradhika-cell Sep 1, 2026
2b57c11
Rename MTLS_URL_PREFIXES to _MTLS_URL_PREFIXES and typecasting fix
agrawalradhika-cell Sep 1, 2026
a149eb7
fix: Refactor request headers handling in sessions.py
agrawalradhika-cell Sep 2, 2026
e66a723
fix: Fix client certificate callback handling in mTLS
agrawalradhika-cell Sep 2, 2026
314d974
feat: Implement mTLS parameter check and fingerprinting
agrawalradhika-cell Sep 2, 2026
cf308b8
fix: Refactor MTLS parameter checking in sessions.py
agrawalradhika-cell Sep 2, 2026
7794506
fix: Update mock patch for MTLS check parameters
agrawalradhika-cell Sep 2, 2026
931bdfd
fix: Refactor mTLS channel reconfiguration logic for lint
agrawalradhika-cell Sep 2, 2026
9bd8e8f
fix: Fix lint errors
agrawalradhika-cell Sep 2, 2026
e8ee7b6
fix: fix the unit tests based on the code changes
agrawalradhika-cell Sep 2, 2026
7379d8a
fix: Log when credentials do not implement refresh method
agrawalradhika-cell Sep 2, 2026
48cf5d2
chore: Refactor authentication retry logic in sessions.py
agrawalradhika-cell Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion packages/google-auth/google/auth/aio/transport/mtls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import ssl
from typing import Optional

from google.auth import exceptions
from google.auth import _agent_identity_utils, exceptions
from google.auth.transport._mtls_helper import secure_cert_key_paths
import google.auth.transport.mtls

Expand Down Expand Up @@ -177,3 +177,30 @@ async def get_client_cert_and_key(client_cert_callback=None):

has_cert, cert, key, _ = await get_client_ssl_credentials()
return has_cert, cert, key


async def check_parameters_for_unauthorized_response(client_cert_callback, cached_cert):
"""Async helper to retrieve certs and compute fingerprints for mTLS rotation."""
is_mtls, call_cert_bytes, call_key_bytes = await get_client_cert_and_key(
client_cert_callback
)
if not is_mtls or not call_cert_bytes:
return None, None, None, None

def _fetch_fingerprints():
cert_obj = _agent_identity_utils.parse_certificate(call_cert_bytes)
current_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(
cert_obj
)
if cached_cert:
cached_fingerprint = _agent_identity_utils.get_cached_cert_fingerprint(
cached_cert
)
else:
cached_fingerprint = current_fingerprint
return cached_fingerprint, current_fingerprint

cached_fingerprint, current_cert_fingerprint = await _run_in_executor(
_fetch_fingerprints
)
return call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint
207 changes: 197 additions & 10 deletions packages/google-auth/google/auth/aio/transport/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@
# limitations under the License.

import asyncio
import collections.abc
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 urllib.parse
import warnings

from google.auth import _exponential_backoff, exceptions
Expand All @@ -37,6 +41,9 @@
except (ImportError, AttributeError):
ClientTimeout = None

_LOGGER = logging.getLogger(__name__)
_MTLS_URL_PREFIXES = ["mtls.googleapis.com", "mtls.sandbox.googleapis.com"]


# Tracks the internal aiohttp installation and usage
try:
Expand Down Expand Up @@ -66,6 +73,8 @@ async def timeout_guard(timeout):
total_timeout = timeout

def _remaining_time():
if total_timeout is None:
return None
elapsed = time.monotonic() - start
remaining = total_timeout - elapsed
if remaining <= 0:
Expand Down Expand Up @@ -143,11 +152,15 @@ def __init__(
self._is_mtls = False
self._mtls_init_task = None
self._cached_cert = None
self._client_cert_callback = None
self._old_auth_requests: list[transport.Request] = []
if _auth_request is None:
raise exceptions.TransportError(
"`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: Optional[asyncio.Lock] = None
self._mtls_check_counter = 0

async def configure_mtls_channel(self, client_cert_callback=None):
"""Configure the client certificate and key for SSL connection.
Expand Down Expand Up @@ -175,6 +188,7 @@ async def configure_mtls_channel(self, client_cert_callback=None):
creation failed for any reason.
"""
if self._mtls_init_task is None:
self._client_cert_callback = client_cert_callback

async def _do_configure():
# Run the blocking check in an executor
Expand Down Expand Up @@ -204,12 +218,8 @@ async def _do_configure():

old_auth_request = self._auth_request
self._auth_request = AiohttpRequest(session=new_session)
self._old_auth_requests.append(old_auth_request)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every rotation appends the old AiohttpRequest to self._old_auth_requests. These requests stay open until session.close(). For long running services with periodic certificate rotation, this list grows without bounds, leaking connection pools and open sockets. Prune or close old sessions in the background after in flight requests finish.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback!

It was an intentional decision to tie the cleanup of old sessions to AsyncAuthorizedSession.close() for a few practical reasons:

  • Low Frequency of Rotation: Certificate rotations for mTLS and Agent Identity typically occur on the scale of hours or days (e.g., every 12 to 24 hours). Even for a long-running service operating continuously for weeks or months, the list will only accumulate a very small number of stale session objects. The memory overhead is negligible in practice.
  • Natural Connection Timeouts (No Socket Leaks): Retaining an aiohttp.ClientSession object in a list does not mean its underlying TCP sockets stay open forever. HTTP connection pools rely on keep-alive timeouts. Once the final in-flight request finishes, the connection sits idle in the pool. Shortly after, the client-side timeout or the server-side idle timeout will trigger and cleanly close the TCP socket. The lightweight session object is retained in Python memory, but the underlying system sockets and connection pool resources are properly freed.
  • Complexity of Background Pruning: As noted in previous comments, closing the active ClientSession immediately aborts concurrent in-flight requests. Working around this by implementing a mechanism to "prune after in-flight requests finish" introduces significant architectural complexity. We would have to implement manual request reference-counting or orchestrate a background asyncio.Task to monitor the session. Background tasks in library code are notoriously tricky and carry a high risk of dangling task warnings on shutdown or unhandled exceptions.

Given the infrequency of rotations and the fact that idle sockets naturally time out and close on their own, accepting a slow-growing list of empty session objects seemed like the safest tradeoff compared to the complexity and risk of implementing background garbage collection.

Let me know if you still have concerns here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can solve this cleanly without background tasks. Since rotations occur hours apart and _do_configure() is already an async coroutine, can we consider bounding self._old_auth_requests to two generations and awaiting close() in-line on the oldest entry when appending a new one?
This gives in-flight requests on the immediately displaced session ample time to complete, and bounds retention.


try:
await old_auth_request.close()
except Exception:
# Suppress so it doesn't abort the mTLS configuration
pass
else:
is_mtls = False
warnings.warn(
Expand Down Expand Up @@ -277,7 +287,10 @@ 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.
"""
_auth_retry_count = kwargs.pop("_auth_retry_count", 0)
if self._mtls_init_task:
try:
await self._mtls_init_task
Expand All @@ -288,13 +301,13 @@ async def request(
retries = _exponential_backoff.AsyncExponentialBackoff(
total_attempts=total_attempts,
)
if headers is None:
headers = {}
request_headers = dict(headers) if headers is not None else {}
start_time = time.monotonic()
async with timeout_guard(max_allowed_time) as with_timeout:
await with_timeout(
# Note: before_request will attempt to refresh credentials if expired.
self._credentials.before_request(
self._auth_request, method, url, headers
self._auth_request, method, url, request_headers
)
)
actual_timeout: float = 0.0
Expand All @@ -307,11 +320,177 @@ async def request(
async for _ in retries: # pragma: no branch
response = await with_timeout(
self._auth_request(
url, method, data, headers, actual_timeout, **kwargs
url, method, data, request_headers, actual_timeout, **kwargs
)
)

if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES:
break

if response.status_code == http_client.UNAUTHORIZED:
Comment thread
agrawalradhika-cell marked this conversation as resolved.
Comment thread
agrawalradhika-cell marked this conversation as resolved.
if _auth_retry_count < 2:
try:
if max_allowed_time is not None:
elapsed = time.monotonic() - start_time
remaining_time = max(0.0, max_allowed_time - elapsed)
if remaining_time == 0.0:
raise google.auth.exceptions.TimeoutError(
"Timeout exceeded before credential refresh could begin"
)
else:
remaining_time = None
is_streaming = data is not None and (
isinstance(
data, (collections.abc.Iterator, collections.abc.AsyncIterable)
)
or hasattr(data, "read")
)
async def _recover_auth_state():
is_mtls_endpoint = False
if getattr(self, "is_mtls", False):
hostname = urllib.parse.urlsplit(url).hostname
if hostname:
is_mtls_endpoint = any(
hostname == prefix or hostname.endswith("." + prefix)
for prefix in _MTLS_URL_PREFIXES
)
# Snapshot the stale certificate state BEFORE acquiring the lock.
# This represents the cert that caused the 401 rejection.
if is_mtls_endpoint:
if self._mtls_rotation_lock is None:
self._mtls_rotation_lock = asyncio.Lock()
# Snapshot the counter state BEFORE acquiring the lock.
check_counter_at_error = self._mtls_check_counter
async with self._mtls_rotation_lock:
# Check if another coroutine already reconfigured mTLS or
# ran the validation check.
if self._mtls_check_counter > check_counter_at_error:
pass
else:
try:
(
call_cert_bytes,
call_key_bytes,
cached_fingerprint,
current_cert_fingerprint,
) = await mtls.check_parameters_for_unauthorized_response(
self._client_cert_callback,
self._cached_cert,
)
except (
exceptions.ClientCertError,
exceptions.MutualTLSChannelError,
OSError,
ValueError,
ImportError,
) as e:
_LOGGER.warning(
"Failed to check client certificate parameters: %s. Proceeding with original response.",
e,
)
return response
else:
if (
current_cert_fingerprint is not None
and cached_fingerprint
!= current_cert_fingerprint
):
saved_callback = self._client_cert_callback
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,
)
)
except Exception as e:
_LOGGER.error(
"Failed to reconfigure mTLS channel: %s",
e,
)
raise exceptions.MutualTLSChannelError(
"Failed to reconfigure mTLS channel"
) from e
finally:
self._client_cert_callback = (
saved_callback
)
else:
_LOGGER.info(
"Skipping reconfiguration of mTLS channel because the client"
" certificate has not changed."
)
finally:
# Always increment so waiting tasks skip the check block
self._mtls_check_counter += 1
try:
await self._credentials.refresh(self._auth_request)
except NotImplementedError:
_LOGGER.debug("Credentials do not implement refresh().")
except (
exceptions.RefreshError,
getattr(exceptions, "InvalidOperation", Exception),
) as e:
_LOGGER.debug(
"Credential refresh failed, returning 401 response. Error: %s",
e,
)
return response

if is_streaming:
return response
# Return None to explicitly signal successful recovery & trigger retry if needed
return None
async with timeout_guard(remaining_time) as auth_with_timeout:
early_return_response = await auth_with_timeout(
_recover_auth_state()
)
except (Exception, asyncio.CancelledError):
if hasattr(response, "close"):
try:
if asyncio.iscoroutinefunction(response.close):
await response.close()
else:
response.close()
except Exception:
pass
raise
# If it returned a response (meaning streaming or error), bail out
if early_return_response is not None:
return early_return_response
if hasattr(response, "close"):
if asyncio.iscoroutinefunction(response.close):
await response.close()
else:
response.close()
if max_allowed_time is not None:
remaining_time = max(
0.0, max_allowed_time - (time.monotonic() - start_time)
)
if remaining_time == 0.0:
raise google.auth.exceptions.TimeoutError(
"Timeout exceeded before retrying the request"
)
kwargs["_auth_retry_count"] = _auth_retry_count + 1
return await self.request(
method,
url,
data=data,
headers=headers,
Comment thread
agrawalradhika-cell marked this conversation as resolved.
max_allowed_time=remaining_time,
timeout=timeout,
total_attempts=total_attempts,
**kwargs,
)
return response

@functools.wraps(request)
Expand Down Expand Up @@ -594,4 +773,12 @@ async def close(self) -> None:
await self._mtls_init_task
except asyncio.CancelledError:
pass
await self._auth_request.close()
try:
await self._auth_request.close()
finally:
for old_request in self._old_auth_requests:
try:
await old_request.close()
except Exception:
pass
self._old_auth_requests.clear()
9 changes: 7 additions & 2 deletions packages/google-auth/google/auth/transport/_mtls_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,19 +808,24 @@ def check_use_client_cert():
return False


def check_parameters_for_unauthorized_response(cached_cert):
def check_parameters_for_unauthorized_response(cached_cert, client_cert_callback=None):
"""Returns the cached and current cert fingerprint for reconfiguring mTLS.

Args:
cached_cert(bytes): The cached client certificate.
client_cert_callback(Optional[Callable[[], (bytes, bytes)]]):
The optional callback that returns the client certificate and private key bytes.

Returns:
bytes: The client callback cert bytes.
bytes: The client callback key bytes.
str: The base64-encoded SHA256 cached fingerprint.
str: The base64-encoded SHA256 current cert fingerprint.
"""
call_cert_bytes, call_key_bytes = call_client_cert_callback()
if client_cert_callback:
call_cert_bytes, call_key_bytes = client_cert_callback()
else:
call_cert_bytes, call_key_bytes = call_client_cert_callback()
cert_obj = _agent_identity_utils.parse_certificate(call_cert_bytes)
current_cert_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(
cert_obj
Expand Down
6 changes: 4 additions & 2 deletions packages/google-auth/tests/transport/aio/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ async def test_timeout_with_simple_async_task_within_bounds(
self, simple_async_task
):
task = False
with patch("time.monotonic", side_effect=[0, 0.25, 0.75]):
with patch(
"time.monotonic", side_effect=lambda it=iter([0, 0.25]): next(it, 0.75)
):
with patch("asyncio.wait_for", lambda coro, _: coro):
async with self.make_timeout_guard(
timeout=self.default_timeout
Expand Down Expand Up @@ -255,7 +257,7 @@ async def test_request_raises_transport_error(self):
async def test_request_max_allowed_time_exceeded_error(self):
auth_request = MockRequest(side_effect=TransportError)
authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request)
with patch("time.monotonic", side_effect=[0, 1, 1]):
with patch("time.monotonic", side_effect=[0, 0] + [2] * 10):
with pytest.raises(TimeoutError):
await authed_session.request("GET", self.TEST_URL, max_allowed_time=1)

Expand Down
Loading
Loading