-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(auth): Add mTLS reconfiguration logic when certificate mismatch for existing credentials & Agent Identity workloads #18224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
94a1d95
420447c
907cf00
cc850b1
a44acb0
984e47c
30341bc
1c068dc
6fb1e86
2cdfe2d
d734731
97e91d0
d0da58b
825426d
63e587c
71b3bf5
7d92d30
8b2efcf
a4d0405
2d52a21
2806f4f
d5426f2
968a9fd
9d1a690
85d4a76
d1c6512
b28caea
f176eed
a436abe
fbee990
b310e27
55f1ad4
554a571
eb28f81
221810e
445c576
6b0edd3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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: | ||
|
|
@@ -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 | ||
| 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. | ||
|
|
@@ -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 | ||
|
|
@@ -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( | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
@@ -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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and + or? please update with explicit groupings e.g. 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The variables 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 |
||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
agrawalradhika-cell marked this conversation as resolved.
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) | ||
|
|
@@ -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() | ||
There was a problem hiding this comment.
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] = []