Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
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
145 changes: 140 additions & 5 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 @@ -143,11 +150,14 @@ def __init__(
self._is_mtls = False
self._mtls_init_task = None
self._cached_cert = None
self._client_cert_callback = None
self._old_auth_requests = [] # type: list

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 don't think we use #type.

Use explicit type annotations e.g. `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 = None # type: Optional[asyncio.Lock]

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 +185,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 +215,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)

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 +284,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 @@ -290,6 +300,7 @@ async def request(
)
if headers is None:
headers = {}
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.
Expand All @@ -310,8 +321,125 @@ async def request(
url, method, data, headers, actual_timeout, **kwargs
)
)

if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES:
break

if response.status_code == http_client.UNAUTHORIZED:

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.

there's a lot of code to handle this ...

would it be possible to have a _handle_unauthorized helper method?

if _auth_retry_count < 2:
is_streaming = (

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.

and + or?

please update with explicit groupings

e.g.

is_streaming = data is not None and (
    isinstance(data, (collections.abc.Iterator, collections.abc.AsyncIterable))
    or hasattr(data, "read")
)

or whatever is correct

data is not None
and isinstance(
data, (collections.abc.Iterator, collections.abc.AsyncIterable)
)
or hasattr(data, "read")
)
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:
stale_cert = self._cached_cert

if self._mtls_rotation_lock is None:
self._mtls_rotation_lock = asyncio.Lock()

async with self._mtls_rotation_lock:
# Check Did another coroutine already reconfigure mTLS
if self._cached_cert != stale_cert:
pass
else:
try:
(
call_cert_bytes,

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.

why are these call_cert_bytes and call_key_bytes not used? i dont udnerstand how the mtls channel is being configured

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.

The variables call_cert_bytes and call_key_bytes are returned by check_parameters_for_unauthorized_response because that helper fetches them to calculate the new fingerprint.

However, they aren't directly used here to configure the channel. If we were to pass them into configure_mtls_channel (for example, by wrapping them in a one-time lambda), it would permanently overwrite the saved _client_cert_callback. If the certificate expired a second time, the rotation would silently fail because it would execute the static lambda returning the old bytes instead of checking the file system.

By calling await self.configure_mtls_channel(self._client_cert_callback), we ensure the channel re-evaluates the original callback (or reads the file system again), safely picking up the new certificates.

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._client_cert_callback,
)
except Exception as e:

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.

can we catch a more specific exception?

_LOGGER.warning(
"Failed to check client certificate parameters: %s. Proceeding with original response.",
e,
)
return response
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()
):
self._mtls_init_task = None
await self.configure_mtls_channel(
self._client_cert_callback
)
except Exception as e:
_LOGGER.error(
"Failed to reconfigure mTLS channel: %s",
e,
)
if hasattr(response, "close"):
if asyncio.iscoroutinefunction(
response.close
):
await response.close()
else:
response.close()
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 is_streaming:
return response
try:
await self._credentials.refresh(self._auth_request)
Comment thread
agrawalradhika-cell marked this conversation as resolved.
Comment thread
agrawalradhika-cell marked this conversation as resolved.
except (
exceptions.RefreshError,
getattr(exceptions, "InvalidOperation", Exception),
) as e:
_LOGGER.debug(
"Credential refresh failed, returning 401 response. Error: %s",
e,
)
return response

if hasattr(response, "close"):
if asyncio.iscoroutinefunction(response.close):
await response.close()
else:
response.close()

kwargs["_auth_retry_count"] = _auth_retry_count + 1
elapsed_time = time.monotonic() - start_time
remaining_time = max(0.0, max_allowed_time - elapsed_time)
return await self.request(
method,
url,
data=data,
headers=headers,
max_allowed_time=remaining_time,
timeout=timeout,
total_attempts=total_attempts,
**kwargs,
)
return response

@functools.wraps(request)
Expand Down Expand Up @@ -595,3 +723,10 @@ async def close(self) -> None:
except asyncio.CancelledError:
pass
await self._auth_request.close()

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