diff --git a/packages/google-auth/google/auth/_agent_identity_utils.py b/packages/google-auth/google/auth/_agent_identity_utils.py index 0fa6a92f57ff..5df487b8d0da 100644 --- a/packages/google-auth/google/auth/_agent_identity_utils.py +++ b/packages/google-auth/google/auth/_agent_identity_utils.py @@ -16,6 +16,7 @@ import base64 import hashlib +import json import os import re import stat @@ -73,6 +74,21 @@ def _is_certificate_file_ready(path): return False +def _is_in_well_known_dir(path): + """Checks if the given path is inside the well-known Agent Identity directory.""" + if not path: + return False + well_known_dir = os.path.dirname(_WELL_KNOWN_CERT_PATH) + try: + real_path = os.path.realpath(path) + real_well_known_dir = os.path.realpath(well_known_dir) + return ( + os.path.commonpath([real_well_known_dir, real_path]) == real_well_known_dir + ) + except ValueError: + return False + + def get_agent_identity_certificate_path(): """Gets the agent certificate path from the certificate config file. @@ -98,16 +114,7 @@ def get_agent_identity_certificate_path(): # config file and the certificate file may experience a brief startup latency. # For all other paths, we return early to avoid introducing unnecessary startup # delays. - well_known_dir = os.path.dirname(_WELL_KNOWN_CERT_PATH) - try: - abs_cert_path = os.path.abspath(cert_config_path) - abs_well_known_dir = os.path.abspath(well_known_dir) - should_poll = ( - os.path.commonpath([abs_well_known_dir, abs_cert_path]) - == abs_well_known_dir - ) - except ValueError: - should_poll = False + should_poll = _is_in_well_known_dir(cert_config_path) return _get_cert_path_with_optional_polling(cert_config_path, should_poll) @@ -141,9 +148,9 @@ def _get_cert_path_with_optional_polling(cert_config_path, should_poll): if _is_certificate_file_ready(cert_path): return cert_path - # The config was parsed, but the cert file is not ready yet - if not should_poll: - # If polling is disabled, return early. + # The config was parsed, but the cert file is not ready yet. + # Only poll if both the config path and cert path are in the well-known directory. + if not (should_poll and _is_in_well_known_dir(cert_path)): return None if not has_logged_cert_warning: @@ -182,7 +189,7 @@ def _get_cert_path_with_optional_polling(cert_config_path, should_poll): raise exceptions.RefreshError( "Certificate config or certificate file not found after multiple retries. " f"Token binding protection is failing. You can turn off this protection by setting " - f"{environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES} to false " + f"{environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} to false " "to fall back to unbound tokens." ) @@ -203,8 +210,6 @@ def _parse_cert_path_from_config(cert_config_path): KeyError: If the certificate config file does not contain the expected structure. """ - import json - with open(cert_config_path, "r", encoding="utf-8") as f: cert_config = json.load(f) @@ -221,64 +226,105 @@ def _parse_cert_path_from_config(cert_config_path): return workload_config["cert_path"] -def get_and_parse_agent_identity_certificate(): +def _is_bound_token_opted_out(): + """Returns True only if bound tokens are explicitly disabled via env vars.""" + val = os.environ.get( + environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "" + ).strip() + if not val: + # Fall back to the deprecated env var for backward compatibility + val = os.environ.get( + environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, + "true", + ).strip() + return val.lower() == "false" + + +def get_agent_identity_certificate_and_bytes(): """Gets and parses the agent identity certificate if not opted out. Checks if the user has opted out of certificate-bound tokens. If not, it gets the certificate path, reads the file, and parses it. Returns: - The parsed certificate object if found and not opted out, otherwise None. + Tuple[Optional[cryptography.x509.Certificate], Optional[bytes]]: A tuple + of (parsed certificate object, certificate bytes) if found and not + opted out, otherwise (None, None). """ # If the user has opted out of cert bound tokens, there is no need to # look up the certificate. - is_opted_out = ( - os.environ.get( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, - "true", - ).lower() - == "false" - ) - if is_opted_out: - return None + if _is_bound_token_opted_out(): + return None, None # Respect explicit opt-out of mTLS / client certs from google.auth.transport import _mtls_helper env_override = _mtls_helper._check_use_client_cert_env() if env_override is False: - return None + return None, None cert_path = get_agent_identity_certificate_path() if not cert_path: - return None + return None, None try: with open(cert_path, "rb") as cert_file: - cert_bytes = cert_file.read() - except PermissionError as e: + raw_bytes = cert_file.read() + except OSError as e: warnings.warn( f"Failed to read agent identity certificate file at {cert_path}: {e}. " "Token binding protection cannot be enabled. Falling back to unbound tokens." ) - return None + return None, None - return parse_certificate(cert_bytes) + cert_blocks = _mtls_helper._CERT_REGEX.findall(raw_bytes) + if not cert_blocks: + warnings.warn( + f"No PEM certificate blocks found in {cert_path}. " + "Token binding protection cannot be enabled. Falling back to unbound tokens." + ) + return None, None + + cert_bytes = b"\n".join(block.strip() for block in cert_blocks) + b"\n" + try: + return parse_certificate(raw_bytes), cert_bytes + except (ValueError, ImportError) as e: + warnings.warn( + f"Failed to parse agent identity certificate at {cert_path}: {e}. " + "Token binding protection cannot be enabled. Falling back to unbound tokens." + ) + return None, None def parse_certificate(cert_bytes): - """Parses a PEM-encoded certificate. + """Parses a PEM-encoded certificate or certificate chain and returns the leaf certificate. Args: cert_bytes (bytes): The PEM-encoded certificate bytes. Returns: - cryptography.x509.Certificate: The parsed certificate object. + cryptography.x509.Certificate: The leaf (first) parsed certificate object. + + Raises: + ValueError: If no certificates are found or any certificate in the chain + is malformed. + ImportError: If the cryptography library is not installed. """ + if not cert_bytes: + raise ValueError("Certificate bytes cannot be empty or None.") + + from google.auth.transport import _mtls_helper + try: from cryptography import x509 - return x509.load_pem_x509_certificate(cert_bytes) + cert_blocks = _mtls_helper._CERT_REGEX.findall(cert_bytes) + if not cert_blocks: + return x509.load_pem_x509_certificate(cert_bytes) + if _mtls_helper._has_unmatched_pem_markers(cert_bytes, cert_blocks): + raise ValueError("Malformed or truncated PEM certificate chain.") + certs = [x509.load_pem_x509_certificate(block) for block in cert_blocks] + return certs[0] except ImportError as e: raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e @@ -350,8 +396,9 @@ def calculate_certificate_fingerprint(cert): def should_request_bound_token(cert): """Determines if a bound token should be requested. - This is based on the GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES - environment variable and whether the certificate is an agent identity cert. + This is based on the GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN env var + (falls back to the deprecated GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES + if unset or empty) and whether the certificate is an agent identity cert. Args: cert (cryptography.x509.Certificate): The parsed certificate object. @@ -359,15 +406,7 @@ def should_request_bound_token(cert): Returns: bool: True if a bound token should be requested, False otherwise. """ - is_agent_cert = _is_agent_identity_certificate(cert) - is_opted_in = ( - os.environ.get( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, - "true", - ).lower() - == "true" - ) - if not (is_agent_cert and is_opted_in): + if _is_bound_token_opted_out(): return False # Respect explicit opt-out of mTLS / client certs @@ -377,7 +416,7 @@ def should_request_bound_token(cert): if env_override is False: return False - return True + return _is_agent_identity_certificate(cert) def get_cached_cert_fingerprint(cached_cert): diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 2887e12c2b07..9072472e09c6 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -19,7 +19,6 @@ import inspect import logging import time -import urllib.parse import warnings from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Mapping, Optional, Union @@ -43,11 +42,6 @@ ClientTimeout = None _LOGGER = logging.getLogger(__name__) -_MTLS_URL_PREFIXES = [ - "mtls.googleapis.com", - "mtls.sandbox.googleapis.com", - "p.googleapis.com", -] # Tracks the internal aiohttp installation and usage try: @@ -371,15 +365,10 @@ async def request( ) async def _recover_auth_state(): - is_mtls_endpoint = False if self._is_mtls: - hostname = urllib.parse.urlsplit(url).hostname - if hostname: - is_mtls_endpoint = any( - hostname == prefix - or hostname.endswith("." + prefix) - for prefix in _MTLS_URL_PREFIXES - ) + is_mtls_endpoint = ( + google.auth.transport._mtls_helper.is_mtls_endpoint(url) + ) # Snapshot the stale certificate state BEFORE acquiring the lock. # This represents the cert that caused the 401 rejection. if is_mtls_endpoint: diff --git a/packages/google-auth/google/auth/compute_engine/_metadata.py b/packages/google-auth/google/auth/compute_engine/_metadata.py index 58c509dfa768..ed70fbf24ba5 100644 --- a/packages/google-auth/google/auth/compute_engine/_metadata.py +++ b/packages/google-auth/google/auth/compute_engine/_metadata.py @@ -27,7 +27,14 @@ import requests -from google.auth import _helpers, environment_vars, exceptions, metrics, transport +from google.auth import ( + _agent_identity_utils, + _helpers, + environment_vars, + exceptions, + metrics, + transport, +) from google.auth._exponential_backoff import ExponentialBackoff from google.auth.compute_engine import _mtls @@ -250,6 +257,8 @@ def get( headers=None, return_none_for_not_found_error=False, timeout=_METADATA_DEFAULT_TIMEOUT, + method="GET", + body=None, ): """Fetch a resource from the metadata server. @@ -271,6 +280,8 @@ def get( return_none_for_not_found_error (Optional[bool]): If True, returns None for 404 error instead of throwing an exception. timeout (int): How long to wait, in seconds for the metadata server to respond. + method (str): The HTTP method to use for the request. Defaults to "GET". + body (Optional[bytes]): The HTTP request body payload to send. Defaults to None. Returns: Union[Mapping, str]: If the metadata server returns JSON, a mapping of @@ -283,8 +294,12 @@ def get( google.auth.exceptions.MutualTLSChannelError: if using mtls and the environment configuration is invalid for mTLS (for example, the metadata host has been overridden in strict mTLS mode). + ValueError: if a request body is specified with the GET method. """ + if body is not None and method.upper() == "GET": + raise ValueError("Request body cannot be specified with GET method.") + use_mtls = _mtls.should_use_mds_mtls() # Prepare the request object for mTLS if needed. # This will create a new request object with the mTLS session. @@ -314,9 +329,15 @@ def get( last_exception = None for attempt in backoff: try: - response = request( - url=url, method="GET", headers=headers_to_use, timeout=timeout - ) + kwargs = { + "url": url, + "method": method, + "headers": headers_to_use, + "timeout": timeout, + } + if body is not None: + kwargs["body"] = body + response = request(**kwargs) if response.status in transport.DEFAULT_RETRYABLE_STATUS_CODES: _LOGGER.warning( "Compute Engine Metadata server unavailable on " @@ -460,6 +481,32 @@ def get_service_account_info(request, service_account="default"): return get(request, path, params={"recursive": "true"}) +def _build_token_request_options(metrics_header_value): + """Returns (method, body, headers) for a metadata server token request. + + Defaults to a standard GET request with the x-goog-api-client metrics header. + Upgrades to a POST request with a JSON certificate_chain body and + Content-Type header if an Agent Identity certificate is present and bound + tokens are enabled. + + Args: + metrics_header_value (str): Value for the x-goog-api-client header. + + Returns: + Tuple[str, Optional[bytes], Mapping[str, str]]: A tuple of + (HTTP method, request body bytes, request headers). + """ + headers = {metrics.API_CLIENT_HEADER: metrics_header_value} + cert, cert_bytes = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + if cert and _agent_identity_utils.should_request_bound_token(cert): + headers["Content-Type"] = "application/json" + body = json.dumps({"certificate_chain": cert_bytes.decode("utf-8")}).encode( + "utf-8" + ) + return "POST", body, headers + return "GET", None, headers + + def get_service_account_token(request, service_account="default", scopes=None): """Get the OAuth 2.0 access token for a service account. @@ -478,26 +525,20 @@ def get_service_account_token(request, service_account="default", scopes=None): google.auth.exceptions.TransportError: if an error occurred while retrieving metadata. """ - from google.auth import _agent_identity_utils - params = {} if scopes: if not isinstance(scopes, str): scopes = ",".join(scopes) params["scopes"] = scopes - cert = _agent_identity_utils.get_and_parse_agent_identity_certificate() - if cert: - if _agent_identity_utils.should_request_bound_token(cert): - fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(cert) - params["bindCertificateFingerprint"] = fingerprint - - metrics_header = { - metrics.API_CLIENT_HEADER: metrics.token_request_access_token_mds() - } + method, body, headers = _build_token_request_options( + metrics.token_request_access_token_mds() + ) path = "instance/service-accounts/{0}/token".format(service_account) - token_json = get(request, path, params=params, headers=metrics_header) + token_json = get( + request, path, params=params, headers=headers, method=method, body=body + ) token_expiry = _helpers.utcnow() + datetime.timedelta( seconds=token_json["expires_in"] ) diff --git a/packages/google-auth/google/auth/compute_engine/credentials.py b/packages/google-auth/google/auth/compute_engine/credentials.py index d0d24b41f4e4..763d2a6f231e 100644 --- a/packages/google-auth/google/auth/compute_engine/credentials.py +++ b/packages/google-auth/google/auth/compute_engine/credentials.py @@ -530,11 +530,17 @@ def _call_metadata_identity_endpoint(self, request): try: path = "instance/service-accounts/default/identity" params = {"audience": self._target_audience, "format": "full"} - metrics_header = { - metrics.API_CLIENT_HEADER: metrics.token_request_id_token_mds() - } + method, body, headers = _metadata._build_token_request_options( + metrics.token_request_id_token_mds() + ) + id_token = _metadata.get( - request, path, params=params, headers=metrics_header + request, + path, + params=params, + headers=headers, + method=method, + body=body, ) except exceptions.TransportError as caught_exc: new_exc = exceptions.RefreshError(caught_exc) diff --git a/packages/google-auth/google/auth/environment_vars.py b/packages/google-auth/google/auth/environment_vars.py index 54550945f994..e8f3fb6e0670 100644 --- a/packages/google-auth/google/auth/environment_vars.py +++ b/packages/google-auth/google/auth/environment_vars.py @@ -128,10 +128,22 @@ """Environment variable defining the location of Google API certificate config file. This variable is the fallback of GOOGLE_API_CERTIFICATE_CONFIG.""" +GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN = "GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN" +"""Environment variable controlling whether to enable runtime bound tokens. + +Defaults to enabled; only a case-insensitive ``"false"`` disables it. When set +to a non-empty value, this variable takes precedence over +:data:`GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES`. +""" + GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = ( "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES" ) -"""Environment variable to prevent agent token sharing for GCP services.""" +"""Environment variable to prevent agent token sharing for GCP services. + +.. deprecated:: v2.59.0 + Use :data:`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN` instead. +""" GOOGLE_API_USE_MTLS_ENDPOINT = "GOOGLE_API_USE_MTLS_ENDPOINT" """Environment variable controlling whether to use mTLS endpoint or not.""" diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 41b312e4f841..06ff6f53393c 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -572,12 +572,12 @@ def refresh(self, request): if self._credential_source_certificate is not None: try: cert_bytes = self._get_cert_bytes() - except (exceptions.ClientCertError, OSError) as e: + cert = _agent_identity_utils.parse_certificate(cert_bytes) + except (exceptions.ClientCertError, OSError, ValueError) as e: raise exceptions.RefreshError( - "Failed to retrieve certificate bytes for external" + "Failed to retrieve or parse certificate for external" " account credentials" ) from e - cert = _agent_identity_utils.parse_certificate(cert_bytes) if _agent_identity_utils.should_request_bound_token(cert): cert_fingerprint = ( _agent_identity_utils.calculate_certificate_fingerprint(cert) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 469932be058c..be67450c748c 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -34,7 +34,7 @@ CERTIFICATE_CONFIGURATION_DEFAULT_PATH = "~/.config/gcloud/certificate_config.json" _CERT_PROVIDER_COMMAND = "cert_provider_command" _CERT_REGEX = re.compile( - b"-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\r?\n?", re.DOTALL + b"-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----\r?\n?", re.DOTALL ) # support various format of key files, e.g. @@ -43,7 +43,7 @@ # "-----BEGIN RSA PRIVATE KEY-----..." # "-----BEGIN ENCRYPTED PRIVATE KEY-----" _KEY_REGEX = re.compile( - b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+-----END [A-Z ]*PRIVATE KEY-----\r?\n?", + b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+?-----END [A-Z ]*PRIVATE KEY-----\r?\n?", re.DOTALL, ) @@ -529,18 +529,32 @@ def _read_cert_and_key_files(cert_path, key_path): return cert_data, key_data +def _join_cert_chain(cert_match): + return b"".join( + m if m.endswith(b"\n") or i == len(cert_match) - 1 else m + b"\n" + for i, m in enumerate(cert_match) + ) + + +def _has_unmatched_pem_markers(pem_bytes, cert_blocks): + """Checks that every BEGIN/END CERTIFICATE marker belongs to a complete cert block.""" + return len(cert_blocks) != pem_bytes.count(b"-----BEGIN CERTIFICATE-----") or len( + cert_blocks + ) != pem_bytes.count(b"-----END CERTIFICATE-----") + + def _read_cert_file(cert_path): with open(cert_path, "rb") as cert_file: cert_data = cert_file.read() cert_match = re.findall(_CERT_REGEX, cert_data) - if len(cert_match) != 1: + if not cert_match or _has_unmatched_pem_markers(cert_data, cert_match): raise exceptions.ClientCertError( - "Certificate file {} is in an invalid format, a single PEM formatted certificate is expected".format( + "Certificate file {} is in an invalid format, at least one PEM formatted certificate is expected".format( cert_path ) ) - return cert_match[0] + return _join_cert_chain(cert_match) def _read_key_file(key_path): @@ -591,8 +605,9 @@ def _run_cert_provider_command(command, expect_encrypted_key=False): # Extract certificate (chain), key and passphrase. cert_match = re.findall(_CERT_REGEX, stdout) - if len(cert_match) != 1: + if not cert_match or _has_unmatched_pem_markers(stdout, cert_match): raise exceptions.ClientCertError("Client SSL certificate is missing or invalid") + cert_chain = _join_cert_chain(cert_match) key_match = re.findall(_KEY_REGEX, stdout) if len(key_match) != 1: raise exceptions.ClientCertError("Client SSL key is missing or invalid") @@ -603,13 +618,13 @@ def _run_cert_provider_command(command, expect_encrypted_key=False): raise exceptions.ClientCertError("Passphrase is missing or invalid") if b"ENCRYPTED" not in key_match[0]: raise exceptions.ClientCertError("Encrypted private key is expected") - return cert_match[0], key_match[0], passphrase_match[0].strip() + return cert_chain, key_match[0], passphrase_match[0].strip() if b"ENCRYPTED" in key_match[0]: raise exceptions.ClientCertError("Encrypted private key is not expected") if len(passphrase_match) > 0: raise exceptions.ClientCertError("Passphrase is not expected") - return cert_match[0], key_match[0], None + return cert_chain, key_match[0], None def get_client_ssl_credentials( @@ -813,15 +828,17 @@ def check_parameters_for_unauthorized_response(cached_cert): """Returns the cached and current cert fingerprint for reconfiguring mTLS. Args: - cached_cert(bytes): The cached client certificate. + cached_cert (Optional[bytes]): The cached client certificate. 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. + Tuple[Optional[bytes], Optional[bytes], Optional[str], Optional[str]]: + The client callback cert bytes, client callback key bytes, + base64-encoded SHA256 cached fingerprint, and base64-encoded SHA256 + current cert fingerprint. """ call_cert_bytes, call_key_bytes = call_client_cert_callback() + if not call_cert_bytes: + return None, None, None, None cert_obj = _agent_identity_utils.parse_certificate(call_cert_bytes) current_cert_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint( cert_obj @@ -847,16 +864,19 @@ def call_client_cert_callback(): ".mtls.googleapis.com", ".mtls.sandbox.googleapis.com", ".p.googleapis.com", + ".mtls.run.app", ) _MTLS_EXACT_HOSTS = ( "mtls.googleapis.com", "mtls.sandbox.googleapis.com", "p.googleapis.com", + "mtls.run.app", ) def is_mtls_endpoint(url: Optional[Union[str, bytes, object]]) -> bool: - """Checks if the given URL corresponds to an mTLS or Private Service Connect (PSC) endpoint. + """Checks if the given URL corresponds to an mTLS (Google APIs or Cloud Run) + or Private Service Connect (PSC) endpoint. Args: url (Optional[Union[str, bytes, object]]): The request URL. diff --git a/packages/google-auth/tests/compute_engine/test__metadata.py b/packages/google-auth/tests/compute_engine/test__metadata.py index 0b151722af07..f41113d358b0 100644 --- a/packages/google-auth/tests/compute_engine/test__metadata.py +++ b/packages/google-auth/tests/compute_engine/test__metadata.py @@ -484,6 +484,57 @@ def test_get_failure_bad_json(): ) +def test_get_body_with_get_method_raises_value_error(): + request = make_request("{}") + + with pytest.raises( + ValueError, match="Request body cannot be specified with GET method." + ): + _metadata.get(request, PATH, method="GET", body=b"some_body") + + request.assert_not_called() + + +@mock.patch("time.sleep", return_value=None) +def test_get_post_retry_preserves_method_body_and_headers(mock_sleep): + response_503 = mock.create_autospec(transport.Response, instance=True) + response_503.status = http_client.SERVICE_UNAVAILABLE + response_503.data = _helpers.to_bytes("Service Unavailable") + response_503.headers = {} + + response_ok = mock.create_autospec(transport.Response, instance=True) + response_ok.status = http_client.OK + response_ok.data = _helpers.to_bytes( + json.dumps({"access_token": "bound_token", "expires_in": 3600}) + ) + response_ok.headers = {"content-type": "application/json"} + + request = mock.create_autospec(transport.Request) + request.side_effect = [ + response_503, + exceptions.TransportError("transient transport error"), + response_ok, + ] + + expected_body = json.dumps({"certificate_chain": "fake_pem_chain"}).encode("utf-8") + result = _metadata.get( + request, + PATH, + method="POST", + body=expected_body, + headers={"Content-Type": "application/json"}, + ) + + assert result == {"access_token": "bound_token", "expires_in": 3600} + assert request.call_count == 3 + for call_args in request.call_args_list: + _, kwargs = call_args + assert kwargs["method"] == "POST" + assert kwargs["body"] == expected_body + assert kwargs["headers"]["Content-Type"] == "application/json" + assert kwargs["headers"][_metadata._METADATA_FLAVOR_HEADER] == "Google" + + def test_get_project_id(): project = "example-project" request = make_request(project, headers={"content-type": "text/plain"}) @@ -636,8 +687,8 @@ def test_get_universe_domain_other_error(): @mock.patch( - "google.auth._agent_identity_utils.get_and_parse_agent_identity_certificate", - return_value=None, + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes", + return_value=(None, None), ) @mock.patch( "google.auth.metrics.token_request_access_token_mds", @@ -669,8 +720,8 @@ def test_get_service_account_token( @mock.patch( - "google.auth._agent_identity_utils.get_and_parse_agent_identity_certificate", - return_value=None, + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes", + return_value=(None, None), ) @mock.patch( "google.auth.metrics.token_request_access_token_mds", @@ -705,8 +756,8 @@ def test_get_service_account_token_with_scopes_list( @mock.patch( - "google.auth._agent_identity_utils.get_and_parse_agent_identity_certificate", - return_value=None, + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes", + return_value=(None, None), ) @mock.patch( "google.auth.metrics.token_request_access_token_mds", @@ -740,10 +791,9 @@ def test_get_service_account_token_with_scopes_string( assert expiry == utcnow() + datetime.timedelta(seconds=ttl) -@mock.patch("google.auth._agent_identity_utils.calculate_certificate_fingerprint") @mock.patch("google.auth._agent_identity_utils.should_request_bound_token") @mock.patch( - "google.auth._agent_identity_utils.get_and_parse_agent_identity_certificate" + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" ) @mock.patch( "google.auth.metrics.token_request_access_token_mds", @@ -753,38 +803,39 @@ def test_get_service_account_token_with_scopes_string( def test_get_service_account_token_with_bound_token( utcnow, mock_metrics_header_value, - mock_get_and_parse, + mock_get_cert_and_bytes, mock_should_request, - mock_calculate_fingerprint, ): # Test the successful path where a certificate is found and a bound token # is requested. mock_cert = mock.sentinel.cert - mock_get_and_parse.return_value = mock_cert + mock_cert_bytes = b"fake_cert_bytes" + mock_get_cert_and_bytes.return_value = (mock_cert, mock_cert_bytes) mock_should_request.return_value = True - mock_calculate_fingerprint.return_value = "fake_fingerprint" token_response = json.dumps({"access_token": "token", "expires_in": 3600}) request = make_request(token_response, headers={"content-type": "application/json"}) _metadata.get_service_account_token(request) - mock_get_and_parse.assert_called_once() + mock_get_cert_and_bytes.assert_called_once() mock_should_request.assert_called_once_with(mock_cert) - mock_calculate_fingerprint.assert_called_once_with(mock_cert) request.assert_called_once() _, kwargs = request.call_args - url = kwargs["url"] - assert "bindCertificateFingerprint=fake_fingerprint" in url + assert kwargs["method"] == "POST" + assert kwargs["body"] == json.dumps( + {"certificate_chain": mock_cert_bytes.decode("utf-8")} + ).encode("utf-8") + assert kwargs["headers"]["Content-Type"] == "application/json" @mock.patch( - "google.auth._agent_identity_utils.get_and_parse_agent_identity_certificate" + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" ) -def test_get_service_account_token_no_cert(mock_get_and_parse): - # Test that no fingerprint is added when no certificate is found. - mock_get_and_parse.return_value = None +def test_get_service_account_token_no_cert(mock_get_cert_and_bytes): + # Test that a standard GET request without a body is sent when no certificate is found. + mock_get_cert_and_bytes.return_value = (None, None) token_response = json.dumps({"access_token": "token", "expires_in": 3600}) request = make_request(token_response, headers={"content-type": "application/json"}) @@ -792,19 +843,20 @@ def test_get_service_account_token_no_cert(mock_get_and_parse): request.assert_called_once() _, kwargs = request.call_args - url = kwargs["url"] - assert "bindCertificateFingerprint" not in url + assert kwargs["method"] == "GET" + assert "body" not in kwargs + assert "Content-Type" not in kwargs["headers"] @mock.patch("google.auth._agent_identity_utils.should_request_bound_token") @mock.patch( - "google.auth._agent_identity_utils.get_and_parse_agent_identity_certificate" + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" ) def test_get_service_account_token_should_not_bind( - mock_get_and_parse, mock_should_request + mock_get_cert_and_bytes, mock_should_request ): - # Test that no fingerprint is added when a cert is found but should not be used. - mock_get_and_parse.return_value = mock.sentinel.cert + # Test that a standard GET request without a body is sent when a cert is found but should not be used. + mock_get_cert_and_bytes.return_value = (mock.sentinel.cert, b"fake_cert_bytes") mock_should_request.return_value = False token_response = json.dumps({"access_token": "token", "expires_in": 3600}) request = make_request(token_response, headers={"content-type": "application/json"}) @@ -813,8 +865,9 @@ def test_get_service_account_token_should_not_bind( request.assert_called_once() _, kwargs = request.call_args - url = kwargs["url"] - assert "bindCertificateFingerprint" not in url + assert kwargs["method"] == "GET" + assert "body" not in kwargs + assert "Content-Type" not in kwargs["headers"] def test_get_service_account_info(): diff --git a/packages/google-auth/tests/compute_engine/test_credentials.py b/packages/google-auth/tests/compute_engine/test_credentials.py index 6e30d8807bf3..c11d50b8e7fc 100644 --- a/packages/google-auth/tests/compute_engine/test_credentials.py +++ b/packages/google-auth/tests/compute_engine/test_credentials.py @@ -13,13 +13,14 @@ # limitations under the License. import base64 import datetime +import json import re from unittest import mock import pytest # type: ignore import responses # type: ignore -from google.auth import _helpers, exceptions, jwt, transport +from google.auth import _helpers, exceptions, jwt, metrics, transport from google.auth.compute_engine import credentials from google.auth.transport import requests @@ -471,29 +472,23 @@ def test_regional_access_boundary_disabled_state_transitions( # Subsequent check calls should return False early assert creds._is_regional_access_boundary_lookup_required() is False - @mock.patch("google.auth.compute_engine._metadata.get") - @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - @mock.patch("google.auth._agent_identity_utils.parse_certificate") @mock.patch( - "google.auth._agent_identity_utils.should_request_bound_token", - return_value=True, + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" ) @mock.patch( - "google.auth._agent_identity_utils.calculate_certificate_fingerprint", - return_value="fingerprint", + "google.auth._agent_identity_utils.should_request_bound_token", + return_value=True, ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) def test_refresh_with_agent_identity( self, - mock_calculate_fingerprint, - mock_should_request, - mock_parse_certificate, - mock_get_path, mock_metadata_get, - tmpdir, + mock_should_request, + mock_get_cert_and_bytes, ): - cert_path = tmpdir.join("cert.pem") - cert_path.write(b"cert_content") - mock_get_path.return_value = str(cert_path) + mock_cert = mock.sentinel.cert + mock_cert_bytes = b"cert_content" + mock_get_cert_and_bytes.return_value = (mock_cert, mock_cert_bytes) mock_metadata_get.side_effect = [ { @@ -506,33 +501,32 @@ def test_refresh_with_agent_identity( self.credentials.refresh(None) assert self.credentials.token == "token" - mock_parse_certificate.assert_called_once_with(b"cert_content") - mock_should_request.assert_called_once_with(mock_parse_certificate.return_value) + mock_get_cert_and_bytes.assert_called_once() + mock_should_request.assert_called_once_with(mock_cert) kwargs = mock_metadata_get.call_args[1] assert kwargs["params"] == { "scopes": "one,two", - "bindCertificateFingerprint": "fingerprint", } + assert kwargs["method"] == "POST" + assert kwargs["body"] == json.dumps( + {"certificate_chain": mock_cert_bytes.decode("utf-8")} + ).encode("utf-8") + assert kwargs["headers"]["Content-Type"] == "application/json" - @mock.patch("google.auth.compute_engine._metadata.get") - @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - @mock.patch("google.auth._agent_identity_utils.parse_certificate") + @mock.patch( + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" + ) @mock.patch( "google.auth._agent_identity_utils.should_request_bound_token", return_value=False, ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) def test_refresh_with_agent_identity_opt_out_or_not_agent( self, - mock_should_request, - mock_parse_certificate, - mock_get_path, mock_metadata_get, - tmpdir, + mock_should_request, + mock_get_cert_and_bytes, ): - cert_path = tmpdir.join("cert.pem") - cert_path.write(b"cert_content") - mock_get_path.return_value = str(cert_path) - mock_metadata_get.side_effect = [ { "email": "service-account@project.iam.gserviceaccount.com", @@ -541,13 +535,44 @@ def test_refresh_with_agent_identity_opt_out_or_not_agent( {"access_token": "token", "expires_in": 500}, ] + mock_cert = mock.sentinel.cert + mock_cert_bytes = b"cert_content" + mock_get_cert_and_bytes.return_value = (mock_cert, mock_cert_bytes) + self.credentials.refresh(None) assert self.credentials.token == "token" - mock_parse_certificate.assert_called_once_with(b"cert_content") - mock_should_request.assert_called_once_with(mock_parse_certificate.return_value) + mock_get_cert_and_bytes.assert_called_once() + mock_should_request.assert_called_once_with(mock_cert) kwargs = mock_metadata_get.call_args[1] - assert "bindCertificateFingerprint" not in kwargs.get("params", {}) + assert kwargs["method"] == "GET" + assert kwargs["body"] is None + assert "Content-Type" not in kwargs["headers"] + + @mock.patch( + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_without_agent_identity_certificate( + self, + mock_metadata_get, + mock_get_cert_and_bytes, + ): + mock_metadata_get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]}, + {"access_token": "token", "expires_in": 500}, + ] + + mock_get_cert_and_bytes.return_value = (None, None) + + self.credentials.refresh(None) + + assert self.credentials.token == "token" + mock_get_cert_and_bytes.assert_called_once() + kwargs = mock_metadata_get.call_args[1] + assert kwargs["method"] == "GET" + assert kwargs["body"] is None + assert "Content-Type" not in kwargs["headers"] def test_set_blocking_regional_access_boundary_lookup(self): creds = self.credentials @@ -855,6 +880,150 @@ def test_with_target_audience_integration(self): assert self.credentials.token is not None + @mock.patch( + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" + ) + @mock.patch( + "google.auth._agent_identity_utils.should_request_bound_token", + return_value=True, + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_with_agent_identity( + self, + mock_metadata_get, + mock_should_request, + mock_get_cert_and_bytes, + ): + id_token = "{}.{}.{}".format( + base64.b64encode(b'{"some":"some"}').decode("utf-8"), + base64.b64encode(b'{"exp": 3210}').decode("utf-8"), + base64.b64encode(b"token").decode("utf-8"), + ) + mock_metadata_get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]}, + id_token, + ] + + mock_cert = mock.sentinel.cert + mock_cert_bytes = b"cert_content" + mock_get_cert_and_bytes.return_value = (mock_cert, mock_cert_bytes) + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, + target_audience="https://audience.com", + use_metadata_identity_endpoint=True, + ) + + self.credentials.refresh(None) + + assert self.credentials.token == id_token + mock_get_cert_and_bytes.assert_called_once() + mock_should_request.assert_called_once_with(mock_cert) + + kwargs = mock_metadata_get.call_args[1] + assert kwargs["method"] == "POST" + assert kwargs["body"] == json.dumps( + {"certificate_chain": mock_cert_bytes.decode("utf-8")} + ).encode("utf-8") + assert kwargs["headers"]["Content-Type"] == "application/json" + assert ( + kwargs["headers"][metrics.API_CLIENT_HEADER] + == metrics.token_request_id_token_mds() + ) + + @mock.patch( + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" + ) + @mock.patch( + "google.auth._agent_identity_utils.should_request_bound_token", + return_value=False, + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_with_agent_identity_opt_out_or_not_agent( + self, + mock_metadata_get, + mock_should_request, + mock_get_cert_and_bytes, + ): + id_token = "{}.{}.{}".format( + base64.b64encode(b'{"some":"some"}').decode("utf-8"), + base64.b64encode(b'{"exp": 3210}').decode("utf-8"), + base64.b64encode(b"token").decode("utf-8"), + ) + mock_metadata_get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]}, + id_token, + ] + + mock_cert = mock.sentinel.cert + mock_cert_bytes = b"cert_content" + mock_get_cert_and_bytes.return_value = (mock_cert, mock_cert_bytes) + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, + target_audience="https://audience.com", + use_metadata_identity_endpoint=True, + ) + + self.credentials.refresh(None) + + assert self.credentials.token == id_token + mock_get_cert_and_bytes.assert_called_once() + mock_should_request.assert_called_once_with(mock_cert) + + kwargs = mock_metadata_get.call_args[1] + assert kwargs["method"] == "GET" + assert kwargs["body"] is None + assert "Content-Type" not in kwargs["headers"] + assert ( + kwargs["headers"][metrics.API_CLIENT_HEADER] + == metrics.token_request_id_token_mds() + ) + + @mock.patch( + "google.auth._agent_identity_utils.get_agent_identity_certificate_and_bytes" + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_without_agent_identity_certificate( + self, + mock_metadata_get, + mock_get_cert_and_bytes, + ): + id_token = "{}.{}.{}".format( + base64.b64encode(b'{"some":"some"}').decode("utf-8"), + base64.b64encode(b'{"exp": 3210}').decode("utf-8"), + base64.b64encode(b"token").decode("utf-8"), + ) + mock_metadata_get.side_effect = [ + {"email": "service-account@example.com", "scopes": ["one", "two"]}, + id_token, + ] + + mock_get_cert_and_bytes.return_value = (None, None) + + request = mock.create_autospec(transport.Request, instance=True) + self.credentials = credentials.IDTokenCredentials( + request=request, + target_audience="https://audience.com", + use_metadata_identity_endpoint=True, + ) + + self.credentials.refresh(None) + + assert self.credentials.token == id_token + mock_get_cert_and_bytes.assert_called_once() + + kwargs = mock_metadata_get.call_args[1] + assert kwargs["method"] == "GET" + assert kwargs["body"] is None + assert "Content-Type" not in kwargs["headers"] + assert ( + kwargs["headers"][metrics.API_CLIENT_HEADER] + == metrics.token_request_id_token_mds() + ) + @mock.patch( "google.auth._helpers.utcnow", return_value=_helpers.utcfromtimestamp(0), diff --git a/packages/google-auth/tests/test_agent_identity_utils.py b/packages/google-auth/tests/test_agent_identity_utils.py index 7448dfa7aa0b..fd29a91ff13e 100644 --- a/packages/google-auth/tests/test_agent_identity_utils.py +++ b/packages/google-auth/tests/test_agent_identity_utils.py @@ -48,28 +48,129 @@ ) -class TestAgentIdentityUtils: - @pytest.fixture(autouse=True) - def clean_env(self, monkeypatch): - monkeypatch.delenv( - environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, - raising=False, - ) - monkeypatch.delenv( - environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE, - raising=False, - ) +# Synthetic self-signed test certificate (CN=agent-identity-test) with SAN URI +# "spiffe://agents.global.proj-12345.system.id.goog/workload". +# To regenerate safely, use cryptography.x509.CertificateBuilder with a +# throwaway key (discard the private key; never commit it) and a dummy project +# ID in the SPIFFE URI. +AGENT_IDENTITY_CERT_BYTES = ( + b"-----BEGIN CERTIFICATE-----\n" + b"MIIDEjCCAfqgAwIBAgIUKZAXnXnxf8hsn+ojS1N8bN3hXrUwDQYJKoZIhvcNAQEL\n" + b"BQAwHjEcMBoGA1UEAwwTYWdlbnQtaWRlbnRpdHktdGVzdDAeFw0yNDAxMDEwMDAw\n" + b"MDBaFw0zNDAxMDEwMDAwMDBaMB4xHDAaBgNVBAMME2FnZW50LWlkZW50aXR5LXRl\n" + b"c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+3eSHkp1oBj7rFehL\n" + b"5VJmBF9KLZ5PXQuZNYrGSpxGJ0Dx1T5ancrl8e66AfAepw9O4zdA+8Afub39PQLh\n" + b"wMTEY3O9Uqetch+2apkwXQ+yYpnorgMwqykY77ptApA8WPHzEOj58FPtyC4UqXJ7\n" + b"YKVpN92lVi1l73XBn6axo/q72KjeEdssR6UMtAd3dbGqY3af/AZNppJWRmCMWs8Z\n" + b"oAuxTH5LqYuxwvCDfYLpmQSbv4IJ/UBjkvjRIlzPo2zHg9PMdf/j6Bg9n3kkFaVH\n" + b"Oep+Zm+DtdT8JvwnG3sQ8Qn/ZCqU0z3DkT//XaElAikLwr/1MFrVHhfrYEWnDvuy\n" + b"9PHxAgMBAAGjSDBGMEQGA1UdEQQ9MDuGOXNwaWZmZTovL2FnZW50cy5nbG9iYWwu\n" + b"cHJvai0xMjM0NS5zeXN0ZW0uaWQuZ29vZy93b3JrbG9hZDANBgkqhkiG9w0BAQsF\n" + b"AAOCAQEAWEyBk7TbetWeQTYEdJwH/pNmiqoCzDYcqCSuNqJhrItHuLmSAlKBGCz6\n" + b"I6ptzY6vT7ARXoW07ivf9Ffl3TMUDLjd5Tkfn1q8JjyM1Ugbfuq7rdF2g9+5h6wg\n" + b"tjeV10LqAimr+fFaNvRiGsMfokuwPyUKYe/9d6x5NhcTTNgMQDG5SWnRe1JqPy94\n" + b"GKilWCyzDl4qzHAU5gc7lZ/6WKbYPwjJDDT4/d3AvNx1O/cQCG7Mz4veDuG2Jqh+\n" + b"FPUqQ4G9RL4zdPuXlbKfSknkmZWld1+adyitai6BzDCG9zkEEVJmLE2/e3XvNC93\n" + b"fa2asspu5y/ViCmPS0J2rzWEk7zI5w==\n" + b"-----END CERTIFICATE-----\n" +) + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + monkeypatch.delenv( + environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, + raising=False, + ) + monkeypatch.delenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + raising=False, + ) + monkeypatch.delenv( + environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE, + raising=False, + ) + monkeypatch.delenv( + environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, + raising=False, + ) + monkeypatch.delenv( + environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, + raising=False, + ) + +class TestAgentIdentityUtils: @mock.patch("cryptography.x509.load_pem_x509_certificate") def test_parse_certificate(self, mock_load_cert): + mock_load_cert.return_value = mock.sentinel.cert result = _agent_identity_utils.parse_certificate(b"cert_bytes") mock_load_cert.assert_called_once_with(b"cert_bytes") - assert result == mock_load_cert.return_value + assert result == mock.sentinel.cert + + @pytest.mark.parametrize("invalid_input", [b"", None]) + def test_parse_certificate_empty_or_none_raises_value_error(self, invalid_input): + with pytest.raises( + ValueError, match="Certificate bytes cannot be empty or None" + ): + _agent_identity_utils.parse_certificate(invalid_input) + + @pytest.mark.parametrize( + "second_cert_block", + [ + # Valid Base64, invalid ASN.1 DER + b"-----BEGIN CERTIFICATE-----\n" + + base64.b64encode(b"not valid asn1 der payload") + + b"\n-----END CERTIFICATE-----\n", + # Corrupted Base64 + b"-----BEGIN CERTIFICATE-----\n!!!not_base64!!!\n-----END CERTIFICATE-----\n", + # Non-UTF-8 bytes + b"-----BEGIN CERTIFICATE-----\n\xff\xfe\xfd\n-----END CERTIFICATE-----\n", + # Truncated block missing END CERTIFICATE + b"-----BEGIN CERTIFICATE-----\nMIIB\n", + # Truncated block missing BEGIN CERTIFICATE + b"MIIB\n-----END CERTIFICATE-----\n", + ], + ) + def test_parse_certificate_full_chain_rejects_malformed_intermediate( + self, second_cert_block + ): + chain_bytes = NON_AGENT_IDENTITY_CERT_BYTES + second_cert_block + with pytest.raises(ValueError): + _agent_identity_utils.parse_certificate(chain_bytes) def test_is_certificate_file_ready_empty_path(self): result = _agent_identity_utils._is_certificate_file_ready("") assert result is False + def test_is_in_well_known_dir_empty_path(self): + assert _agent_identity_utils._is_in_well_known_dir("") is False + + def test_is_in_well_known_dir_resolves_symlinks(self, tmpdir, monkeypatch): + real_run_dir = tmpdir.mkdir("run") + var_dir = tmpdir.mkdir("var") + symlink_var_run = var_dir.join("run") + os.symlink(str(real_run_dir), str(symlink_var_run)) + + well_known_via_symlink = os.path.join( + str(symlink_var_run), + "secrets", + "workload-spiffe-credentials", + "certificates.pem", + ) + monkeypatch.setattr( + "google.auth._agent_identity_utils._WELL_KNOWN_CERT_PATH", + well_known_via_symlink, + ) + + resolved_cert_path = os.path.join( + str(real_run_dir), + "secrets", + "workload-spiffe-credentials", + "certificates.pem", + ) + assert _agent_identity_utils._is_in_well_known_dir(resolved_cert_path) is True + def test_get_agent_identity_certificate_path_empty_env(self, monkeypatch): monkeypatch.delenv( environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, raising=False @@ -207,37 +308,73 @@ def test_calculate_certificate_fingerprint(self): assert fingerprint == expected_fingerprint + @pytest.mark.parametrize( + "primary,fallback,expected", + [ + # Default (both unset) -> not opted out + (None, None, False), + # Explicit opt-in / non-false values on primary -> not opted out + ("true", None, False), + ("TRUE", None, False), + ("1", None, False), + ("invalid", None, False), + ("", None, False), + # Explicit opt-out via primary (including surrounding whitespace) -> opted out + ("false", None, True), + ("FALSE", None, True), + (" false ", None, True), + (" FALSE\n", None, True), + # Primary overrides fallback + ("false", "true", True), + ("true", "false", False), + # Empty or whitespace-only string on primary falls back to secondary + ("", " false ", True), + (" ", " false ", True), + # Fallback when primary is unset + (None, "false", True), + (None, "true", False), + ], + ) + def test_is_bound_token_opted_out(self, monkeypatch, primary, fallback, expected): + if primary is not None: + monkeypatch.setenv( + environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, + primary, + ) + if fallback is not None: + monkeypatch.setenv( + environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, + fallback, + ) + assert _agent_identity_utils._is_bound_token_opted_out() is expected + @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") def test_should_request_bound_token(self, mock_is_agent, monkeypatch): - # Agent cert, default env var (opt-in) + # Agent cert, opted in mock_is_agent.return_value = True - monkeypatch.delenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, - raising=False, - ) - assert _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) - - # Agent cert, explicit opt-in monkeypatch.setenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, + environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true", ) assert _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) - # Agent cert, explicit opt-out + # Agent cert, opted out + mock_is_agent.reset_mock() monkeypatch.setenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, + environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false", ) assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + mock_is_agent.assert_not_called() - # Non-agent cert, opt-in + # Non-agent cert, opted in mock_is_agent.return_value = False monkeypatch.setenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, + environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true", ) assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + mock_is_agent.assert_called_once_with(mock.sentinel.cert) @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") def test_should_request_bound_token_explicit_use_client_cert_false( @@ -249,6 +386,7 @@ def test_should_request_bound_token_explicit_use_client_cert_false( "false", ) assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + mock_is_agent.assert_not_called() @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") def test_should_request_bound_token_explicit_use_client_cert_invalid( @@ -260,6 +398,7 @@ def test_should_request_bound_token_explicit_use_client_cert_invalid( "foo", ) assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + mock_is_agent.assert_not_called() @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") def test_should_request_bound_token_auto_enablement(self, mock_is_agent): @@ -381,9 +520,8 @@ def test_get_agent_identity_certificate_path_failure( _agent_identity_utils.get_agent_identity_certificate_path() assert "not found after multiple retries" in str(excinfo.value) - assert ( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES - in str(excinfo.value) + assert environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN in str( + excinfo.value ) assert mock_sleep.call_count == len(_agent_identity_utils._POLLING_INTERVALS) @@ -412,9 +550,8 @@ def test_get_agent_identity_certificate_path_fail_fast_config_missing( mock_sleep.assert_not_called() @mock.patch("time.sleep") - @mock.patch("google.auth._agent_identity_utils.os.path.exists") def test_get_agent_identity_certificate_path_fail_fast_cert_missing( - self, mock_exists, mock_sleep, tmpdir, monkeypatch + self, mock_sleep, tmpdir, monkeypatch ): # Simulate config path outside well-known dir where config is valid but cert is missing. well_known_path = tmpdir.mkdir("well_known_cert").join("certificates.pem") @@ -434,10 +571,36 @@ def test_get_agent_identity_certificate_path_fail_fast_cert_missing( environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, str(config_path) ) - def exists_side_effect(path): - return path == str(config_path) + result = _agent_identity_utils.get_agent_identity_certificate_path() + + assert result is None + mock_sleep.assert_not_called() - mock_exists.side_effect = exists_side_effect + @mock.patch("time.sleep") + def test_get_agent_identity_certificate_path_well_known_config_external_missing_cert_no_poll( + self, mock_sleep, tmpdir, monkeypatch + ): + well_known_dir = tmpdir.mkdir("workload-spiffe-credentials") + external_dir = tmpdir.mkdir("external_certs") + monkeypatch.setattr( + "google.auth._agent_identity_utils._WELL_KNOWN_CERT_PATH", + str(well_known_dir.join("certificates.pem")), + ) + config_path = well_known_dir.join("config.json") + config_path.write( + json.dumps( + { + "cert_configs": { + "workload": { + "cert_path": str(external_dir.join("missing_cert.pem")) + } + } + } + ) + ) + monkeypatch.setenv( + environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, str(config_path) + ) result = _agent_identity_utils.get_agent_identity_certificate_path() @@ -445,9 +608,8 @@ def exists_side_effect(path): mock_sleep.assert_not_called() @mock.patch("time.sleep") - @mock.patch("google.auth._agent_identity_utils.os.path.exists") def test_get_agent_identity_certificate_path_cert_not_found( - self, mock_exists, mock_sleep, tmpdir, monkeypatch + self, mock_sleep, tmpdir, monkeypatch ): monkeypatch.setattr( "google.auth._agent_identity_utils._WELL_KNOWN_CERT_PATH", @@ -462,11 +624,6 @@ def test_get_agent_identity_certificate_path_cert_not_found( environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, str(config_path) ) - def exists_side_effect(path): - return path == str(config_path) - - mock_exists.side_effect = exists_side_effect - with pytest.raises(exceptions.RefreshError): _agent_identity_utils.get_agent_identity_certificate_path() @@ -589,89 +746,198 @@ def test_get_agent_identity_certificate_path_permission_error_cert_file( mock_sleep.assert_not_called() @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - def test_get_and_parse_agent_identity_certificate_opted_out( - self, mock_get_path, monkeypatch + def test_get_agent_identity_certificate_and_bytes_success( + self, mock_get_path, tmpdir ): - monkeypatch.setenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, - "false", - ) - result = _agent_identity_utils.get_and_parse_agent_identity_certificate() - assert result is None - mock_get_path.assert_not_called() + cert_file = tmpdir.join("cert.pem") + cert_file.write_binary(NON_AGENT_IDENTITY_CERT_BYTES) + mock_get_path.return_value = str(cert_file) + + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + + assert isinstance(cert, x509.Certificate) + assert cert_bytes == NON_AGENT_IDENTITY_CERT_BYTES @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - def test_get_and_parse_agent_identity_certificate_no_path( - self, mock_get_path, monkeypatch + def test_get_agent_identity_certificate_and_bytes_combined_bundle( + self, mock_get_path, tmpdir ): - monkeypatch.setenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, - "true", + non_utf8_bag_attrs = b"Bag Attributes\n friendlyName: \xff\xfe\n" + private_key_pem = ( + b"-----BEGIN PRIVATE KEY-----\n" + b"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n" + b"-----END PRIVATE KEY-----\n" + ) + combined_bundle = ( + non_utf8_bag_attrs + + AGENT_IDENTITY_CERT_BYTES.rstrip(b"\n") + + b" \n" + + private_key_pem + + NON_AGENT_IDENTITY_CERT_BYTES + ) + cert_file = tmpdir.join("credentialbundle.pem") + cert_file.write_binary(combined_bundle) + mock_get_path.return_value = str(cert_file) + + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + + expected_certs = AGENT_IDENTITY_CERT_BYTES + NON_AGENT_IDENTITY_CERT_BYTES + assert isinstance(cert, x509.Certificate) + assert _agent_identity_utils._is_agent_identity_certificate(cert) + assert cert_bytes == expected_certs + assert b"PRIVATE KEY" not in cert_bytes + assert cert_bytes.decode("utf-8") == expected_certs.decode("utf-8") + + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_agent_identity_certificate_and_bytes_no_cert_blocks( + self, mock_get_path, tmpdir + ): + cert_file = tmpdir.join("empty_or_key_only.pem") + cert_file.write_binary( + b"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n" ) - mock_get_path.return_value = None - result = _agent_identity_utils.get_and_parse_agent_identity_certificate() - assert result is None - mock_get_path.assert_called_once() + mock_get_path.return_value = str(cert_file) + + with pytest.warns(UserWarning, match="No PEM certificate blocks found"): + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + + assert cert is None + assert cert_bytes is None - @mock.patch("google.auth._agent_identity_utils.parse_certificate") @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - def test_get_and_parse_agent_identity_certificate_success( - self, mock_get_path, mock_parse_certificate, monkeypatch + def test_get_agent_identity_certificate_and_bytes_os_error( + self, mock_get_path, tmpdir ): - monkeypatch.setenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, - "true", + missing_cert_file = tmpdir.join("deleted_during_rotation.pem") + mock_get_path.return_value = str(missing_cert_file) + + with pytest.warns( + UserWarning, match="Failed to read agent identity certificate file" + ): + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + + assert cert is None + assert cert_bytes is None + + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_agent_identity_certificate_and_bytes_corrupt_cert_value_error( + self, mock_get_path, tmpdir + ): + cert_file = tmpdir.join("corrupt_cert.pem") + cert_file.write_binary( + b"-----BEGIN CERTIFICATE-----\nnot_valid_base64_or_der\n-----END CERTIFICATE-----\n" ) - mock_get_path.return_value = "/fake/cert.pem" - mock_open = mock.mock_open(read_data=b"cert_bytes") + mock_get_path.return_value = str(cert_file) - with mock.patch("builtins.open", mock_open): - result = _agent_identity_utils.get_and_parse_agent_identity_certificate() + with pytest.warns( + UserWarning, match="Failed to parse agent identity certificate" + ): + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() - mock_open.assert_called_once_with("/fake/cert.pem", "rb") - mock_parse_certificate.assert_called_once_with(b"cert_bytes") - assert result == mock_parse_certificate.return_value + assert cert is None + assert cert_bytes is None + @pytest.mark.parametrize( + "corrupt_intermediate", + [ + b"-----BEGIN CERTIFICATE-----\n" + + base64.b64encode(b"invalid_asn1_der_intermediate") + + b"\n-----END CERTIFICATE-----\n", + b"-----BEGIN CERTIFICATE-----\ntruncated_without_end_marker\n", + ], + ) @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - def test_get_and_parse_agent_identity_certificate_use_client_cert_false( + def test_get_agent_identity_certificate_and_bytes_corrupt_intermediate_warns( + self, mock_get_path, corrupt_intermediate, tmpdir + ): + cert_file = tmpdir.join("chain_with_corrupt_intermediate.pem") + cert_file.write_binary(AGENT_IDENTITY_CERT_BYTES + corrupt_intermediate) + mock_get_path.return_value = str(cert_file) + + with pytest.warns( + UserWarning, match="Failed to parse agent identity certificate" + ): + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + + assert cert is None + assert cert_bytes is None + + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_agent_identity_certificate_and_bytes_opted_out( self, mock_get_path, monkeypatch ): monkeypatch.setenv( - environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false", ) - result = _agent_identity_utils.get_and_parse_agent_identity_certificate() - assert result is None + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + assert cert is None + assert cert_bytes is None mock_get_path.assert_not_called() @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - def test_get_and_parse_agent_identity_certificate_use_client_cert_invalid( + def test_get_agent_identity_certificate_and_bytes_no_path(self, mock_get_path): + mock_get_path.return_value = None + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + assert cert is None + assert cert_bytes is None + mock_get_path.assert_called_once() + + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_agent_identity_certificate_and_bytes_use_client_cert_false( self, mock_get_path, monkeypatch ): monkeypatch.setenv( environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, - "foo", + "false", ) - result = _agent_identity_utils.get_and_parse_agent_identity_certificate() - assert result is None + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + assert cert is None + assert cert_bytes is None mock_get_path.assert_not_called() @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") - def test_get_and_parse_agent_identity_certificate_file_read_error( + def test_get_agent_identity_certificate_and_bytes_use_client_cert_invalid( self, mock_get_path, monkeypatch ): monkeypatch.setenv( - environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, - "true", + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + "foo", ) - mock_get_path.return_value = "/fake/cert.pem" - mock_open = mock.mock_open() - mock_open.side_effect = PermissionError("Permission denied") - - with mock.patch("builtins.open", mock_open): - result = _agent_identity_utils.get_and_parse_agent_identity_certificate() - - assert result is None + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + assert cert is None + assert cert_bytes is None + mock_get_path.assert_not_called() def test_get_cached_cert_fingerprint_no_cert(self): with pytest.raises(ValueError, match="mTLS connection is not configured."): @@ -709,3 +975,20 @@ def test_is_agent_identity_certificate_raises_import_error(self): def test_calculate_certificate_fingerprint_raises_import_error(self): with pytest.raises(ImportError, match="The cryptography library is required"): _agent_identity_utils.calculate_certificate_fingerprint(mock.sentinel.cert) + + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_agent_identity_certificate_and_bytes_warns_without_cryptography( + self, mock_get_path, tmpdir + ): + cert_file = tmpdir.join("cert.pem") + cert_file.write_binary(AGENT_IDENTITY_CERT_BYTES) + mock_get_path.return_value = str(cert_file) + + with pytest.warns(UserWarning, match="The cryptography library is required"): + ( + cert, + cert_bytes, + ) = _agent_identity_utils.get_agent_identity_certificate_and_bytes() + + assert cert is None + assert cert_bytes is None diff --git a/packages/google-auth/tests/test_identity_pool.py b/packages/google-auth/tests/test_identity_pool.py index 7d6a7c1b68d8..f366b64283b2 100644 --- a/packages/google-auth/tests/test_identity_pool.py +++ b/packages/google-auth/tests/test_identity_pool.py @@ -1819,7 +1819,7 @@ def test_refresh_cert_error_raises_refresh_error(self, mock_get_cert_bytes): credentials.refresh(None) assert excinfo.match( - "Failed to retrieve certificate bytes for external account credentials" + "Failed to retrieve or parse certificate for external account credentials" ) @mock.patch.object( @@ -1835,9 +1835,30 @@ def test_refresh_os_error_raises_refresh_error(self, mock_get_cert_bytes): with pytest.raises(exceptions.RefreshError) as excinfo: credentials.refresh(None) - msg = "Failed to retrieve certificate bytes for external" + msg = "Failed to retrieve or parse certificate for external" assert excinfo.match(msg + " account credentials") + @mock.patch( + "google.auth._agent_identity_utils.parse_certificate", + side_effect=ValueError("malformed certificate chain"), + ) + @mock.patch.object( + identity_pool.Credentials, "_get_cert_bytes", return_value=b"bad_cert" + ) + def test_refresh_parse_certificate_value_error_raises_refresh_error( + self, mock_get_cert_bytes, mock_parse_certificate + ): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + + assert excinfo.match( + "Failed to retrieve or parse certificate for external account credentials" + ) + @mock.patch("google.auth._agent_identity_utils.parse_certificate") @mock.patch( "google.auth._agent_identity_utils.should_request_bound_token", 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 394a465d95b4..3532f82927cc 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -539,7 +539,14 @@ async def test_psc_endpoint_triggers_cert_rotation(self): await session.close() @pytest.mark.asyncio - async def test_non_mtls_url_bypasses_rotation(self): + @pytest.mark.parametrize( + "non_mtls_url", + [ + "https://pubsub.googleapis.com/test", + "https://my-service-xyz-uc.a.run.app/test", + ], + ) + async def test_non_mtls_url_bypasses_rotation(self, non_mtls_url): """Verifies that standard non-mTLS URLs bypass certificate rotation.""" mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_creds.before_request = mock.AsyncMock(return_value=None) @@ -570,7 +577,7 @@ async def test_non_mtls_url_bypasses_rotation(self): session, "configure_mtls_channel", new_callable=mock.AsyncMock ) as mock_conf, ): - resp = await session.request("GET", "https://pubsub.googleapis.com/test") + resp = await session.request("GET", non_mtls_url) assert resp == mock_resp_200 mock_check.assert_not_called() @@ -862,7 +869,14 @@ async def slow_refresh(*args, **kwargs): await session.close() @pytest.mark.asyncio - async def test_cert_rotation_with_completed_mtls_init_task(self): + @pytest.mark.parametrize( + "mtls_url", + [ + "https://pubsub.mtls.googleapis.com/test", + "https://my-service-123456.us-central1.mtls.run.app/test", + ], + ) + async def test_cert_rotation_with_completed_mtls_init_task(self, mtls_url): """ Verifies that when _mtls_init_task is already completed, receiving a 401 with rotated certificates properly resets _mtls_init_task and reconfigures mTLS. @@ -904,10 +918,8 @@ async def dummy_completed(): ) as mock_conf, ): mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") - # Must use a hostname matching _MTLS_URL_PREFIXES (e.g. *.mtls.googleapis.com) - resp = await session.request( - "GET", "https://pubsub.mtls.googleapis.com/test" - ) + # Must use a hostname matching _mtls_helper.is_mtls_endpoint + resp = await session.request("GET", mtls_url) assert resp == mock_resp_200 mock_conf.assert_called_once() # Verify the previous completed task was cleared during rotation diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 675f54b46324..dffcb211e1cc 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -56,7 +56,7 @@ def check_cert_and_key(content, expected_cert, expected_key): success = True cert_match = re.findall(_mtls_helper._CERT_REGEX, content) - success = success and len(cert_match) == 1 and cert_match[0] == expected_cert + success = success and len(cert_match) >= 1 and b"".join(cert_match) == expected_cert key_match = re.findall(_mtls_helper._KEY_REGEX, content) success = success and len(key_match) == 1 and key_match[0] == expected_key @@ -67,32 +67,40 @@ def check_cert_and_key(content, expected_cert, expected_key): class TestCertAndKeyRegex(object): def test_cert_and_key(self): # Test single cert and single key - check_cert_and_key( + assert check_cert_and_key( pytest.public_cert_bytes + pytest.private_key_bytes, pytest.public_cert_bytes, pytest.private_key_bytes, ) - check_cert_and_key( + assert check_cert_and_key( pytest.private_key_bytes + pytest.public_cert_bytes, pytest.public_cert_bytes, pytest.private_key_bytes, ) # Test cert chain and single key - check_cert_and_key( + assert check_cert_and_key( pytest.public_cert_bytes + pytest.public_cert_bytes + pytest.private_key_bytes, pytest.public_cert_bytes + pytest.public_cert_bytes, pytest.private_key_bytes, ) - check_cert_and_key( + assert check_cert_and_key( pytest.private_key_bytes + pytest.public_cert_bytes + pytest.public_cert_bytes, pytest.public_cert_bytes + pytest.public_cert_bytes, pytest.private_key_bytes, ) + # Test interleaved key between certificates in a combined bundle + assert check_cert_and_key( + pytest.public_cert_bytes + + pytest.private_key_bytes + + pytest.public_cert_bytes, + pytest.public_cert_bytes + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) def test_key(self): # Create some fake keys for regex check. @@ -109,13 +117,13 @@ def test_key(self): /fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQAB -----END EC PRIVATE KEY-----""" - check_cert_and_key( + assert check_cert_and_key( pytest.public_cert_bytes + KEY, pytest.public_cert_bytes, KEY ) - check_cert_and_key( + assert check_cert_and_key( pytest.public_cert_bytes + RSA_KEY, pytest.public_cert_bytes, RSA_KEY ) - check_cert_and_key( + assert check_cert_and_key( pytest.public_cert_bytes + EC_KEY, pytest.public_cert_bytes, EC_KEY ) @@ -200,6 +208,21 @@ def test_success_with_cert_chain(self, mock_popen): assert key == ENCRYPTED_EC_PRIVATE_KEY assert passphrase == PASSPHRASE_VALUE + @pytest.mark.parametrize( + "trailing_bytes", + [ + b"-----BEGIN CERTIFICATE-----\nMIIB\n", + b"MIIB\n-----END CERTIFICATE-----\n", + ], + ) + @mock.patch("subprocess.Popen", autospec=True) + def test_truncated_cert_chain_raises_error(self, mock_popen, trailing_bytes): + mock_popen.return_value = self.create_mock_process( + pytest.public_cert_bytes + trailing_bytes + pytest.private_key_bytes, b"" + ) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._run_cert_provider_command(["command"]) + @mock.patch("subprocess.Popen", autospec=True) def test_missing_cert(self, mock_popen): mock_popen.return_value = self.create_mock_process( @@ -779,6 +802,40 @@ def test_invalid_key_file(self): with pytest.raises(exceptions.ClientCertError): _mtls_helper._read_cert_and_key_files(cert_path, key_path) + def test_combined_bundle_with_interleaved_key(self, tmp_path): + bundle_file = tmp_path / "credentialbundle.pem" + bundle_file.write_bytes( + pytest.public_cert_bytes.rstrip(b"\n") + + pytest.private_key_bytes + + pytest.public_cert_bytes + ) + actual_cert, actual_key = _mtls_helper._read_cert_and_key_files( + str(bundle_file), str(bundle_file) + ) + assert actual_cert == pytest.public_cert_bytes + pytest.public_cert_bytes + assert actual_key == pytest.private_key_bytes + + def test_multiple_keys_raises_error(self, tmp_path): + cert_path = os.path.join(pytest.data_dir, "public_cert.pem") + key_file = tmp_path / "two_keys.pem" + key_file.write_bytes(pytest.private_key_bytes + pytest.private_key_bytes) + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._read_cert_and_key_files(cert_path, str(key_file)) + + @pytest.mark.parametrize( + "trailing_bytes", + [ + b"-----BEGIN CERTIFICATE-----\nMIIB\n", + b"MIIB\n-----END CERTIFICATE-----\n", + ], + ) + def test_truncated_multi_cert_raises_error(self, tmp_path, trailing_bytes): + cert_file = tmp_path / "truncated_chain.pem" + cert_file.write_bytes(pytest.public_cert_bytes + trailing_bytes) + key_path = os.path.join(pytest.data_dir, "privatekey.pem") + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._read_cert_and_key_files(str(cert_file), key_path) + class TestGetCertConfigPath(object): def test_success_with_override(self): @@ -1223,6 +1280,21 @@ def test_check_parameters_for_unauthorized_response_without_cached_cert( mock_call_client_cert_callback.assert_called_once() mock_agent_identity_utils.get_cached_cert_fingerprint.assert_not_called() + @mock.patch("google.auth.transport._mtls_helper.call_client_cert_callback") + @mock.patch("google.auth.transport._mtls_helper._agent_identity_utils") + def test_check_parameters_for_unauthorized_response_no_call_cert( + self, mock_agent_identity_utils, mock_call_client_cert_callback + ): + mock_call_client_cert_callback.return_value = (None, None) + + result = _mtls_helper.check_parameters_for_unauthorized_response( + cached_cert=CERT_MOCK_VAL + ) + + assert result == (None, None, None, None) + mock_call_client_cert_callback.assert_called_once() + mock_agent_identity_utils.parse_certificate.assert_not_called() + @mock.patch("google.auth.transport._mtls_helper.get_client_ssl_credentials") def test_call_client_cert_callback(self, mock_get_client_ssl_credentials): mock_get_client_ssl_credentials.return_value = ( @@ -1913,6 +1985,12 @@ class TestIsMtlsEndpoint(object): "https://p.googleapis.com/", "https://p.googleapis.com:443/v1", "https://p.googleapis.com.", + "https://mtls.run.app", + "https://mtls.run.app/", + "https://my-service-123456.us-central1.mtls.run.app", + "https://my-service-123456.us-central1.mtls.run.app/v1/invocations", + "https://tag---my-service-123456.us-central1.mtls.run.app.", + b"https://my-service-123456.us-central1.mtls.run.app", ], ) def test_is_mtls_endpoint_true(self, url): @@ -1926,6 +2004,11 @@ def test_is_mtls_endpoint_true(self, url): "https://storage.googleapis.com:443/b/my-bucket", "https://storage.googleapis.com:443/bucket/mtls.googleapis.com?pageSize=10#frag", "https://storage.googleapis.com/bucket/mtls.googleapis.com", + "https://my-service-xyz-uc.a.run.app", + "https://my-service-123456.us-central1.run.app", + "https://my-service-xyz-uc.a.run.app/mtls.run.app", + "https://fake-mtls.run.app/v1", + "https://fake-mtls.run.app.attacker.com/v1", "https://[2001:db8::1]:443/mtls.googleapis.com", "https://[::1]:8443/mtls.googleapis.com", "https://logging.googleapis.com/v2/entries?filter=mtls.googleapis.com", @@ -1944,6 +2027,7 @@ def test_is_mtls_endpoint_true(self, url): "https://storage.googleapis.com/bucket/mtls.googleapis.com" ), "https://.", + "https://[::1", "", None, 123, diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index b8fc56664dc6..cc1645819b4f 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -939,7 +939,14 @@ def test_cert_rotation_logic_skipped_on_other_refresh_status_codes(self): # Assert mTLS check logic was SKIPPED (Inner Check was False) assert not mock_helper.check_parameters_for_unauthorized_response.called - def test_cert_rotation_skipped_on_non_mtls_url(self): + @pytest.mark.parametrize( + "non_mtls_url", + [ + "https://storage.googleapis.com/bucket/mtls.googleapis.com", + "https://my-service-xyz-uc.a.run.app/mtls.run.app", + ], + ) + def test_cert_rotation_skipped_on_non_mtls_url(self, non_mtls_url): """ Tests that mTLS cert rotation is skipped on non-mTLS URLs even if mTLS is enabled and an UNAUTHORIZED (401) response is received. @@ -952,7 +959,6 @@ def test_cert_rotation_skipped_on_non_mtls_url(self): make_response(status=http_client.OK), ] ) - non_mtls_url = "https://storage.googleapis.com/bucket/mtls.googleapis.com" authed_session = google.auth.transport.requests.AuthorizedSession( credentials, refresh_timeout=60 ) @@ -972,10 +978,18 @@ def test_cert_rotation_skipped_on_non_mtls_url(self): # Assert mTLS check logic was SKIPPED assert not mock_check_params.called - def test_cert_rotation_triggered_on_psc_url(self): + @pytest.mark.parametrize( + "mtls_url", + [ + "https://storage.p.googleapis.com/b/my-bucket", + "https://my-service-123456.us-central1.mtls.run.app/v1", + ], + ) + def test_cert_rotation_triggered_on_mtls_url(self, mtls_url): """ - Tests that mTLS cert rotation IS triggered on a Private Service Connect - (PSC) mTLS endpoint when an UNAUTHORIZED (401) response is received. + Tests that mTLS cert rotation IS triggered on Private Service Connect + (PSC) and Cloud Run mTLS endpoints when an UNAUTHORIZED (401) response + is received. """ credentials = mock.Mock(wraps=CredentialsStub()) adapter = AdapterStub( @@ -984,11 +998,10 @@ def test_cert_rotation_triggered_on_psc_url(self): make_response(status=http_client.OK), ] ) - psc_url = "https://storage.p.googleapis.com/b/my-bucket" authed_session = google.auth.transport.requests.AuthorizedSession( credentials, refresh_timeout=60 ) - authed_session.mount(psc_url, adapter) + authed_session.mount(mtls_url, adapter) authed_session._is_mtls = True authed_session._cached_cert = b"cached_cert" @@ -997,9 +1010,9 @@ def test_cert_rotation_triggered_on_psc_url(self): "check_parameters_for_unauthorized_response", return_value=(b"new_cert", b"new_key", "old_fp", "old_fp"), ) as mock_check_params: - authed_session.request("GET", psc_url) + authed_session.request("GET", mtls_url) - # Assert mTLS check logic was called on PSC endpoint + # Assert mTLS check logic was called on PSC / Cloud Run mTLS endpoint mock_check_params.assert_called_once() assert credentials.refresh.called diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index b9136a4f9f7c..7ffd50aeee32 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -657,7 +657,14 @@ def test_cert_rotation_logic_skipped_on_other_refresh_status_codes(self): # Assert mTLS check logic was SKIPPED (Inner Check was False) assert not mock_helper.check_parameters_for_unauthorized_response.called - def test_cert_rotation_skipped_on_non_mtls_url(self): + @pytest.mark.parametrize( + "non_mtls_url", + [ + "https://storage.googleapis.com/bucket/mtls.googleapis.com", + "https://my-service-xyz-uc.a.run.app/mtls.run.app", + ], + ) + def test_cert_rotation_skipped_on_non_mtls_url(self, non_mtls_url): """ Tests that mTLS cert rotation is skipped on non-mTLS URLs even if mTLS is enabled and an UNAUTHORIZED (401) response is received. @@ -669,7 +676,6 @@ def test_cert_rotation_skipped_on_non_mtls_url(self): ResponseStub(status=http_client.OK), ] ) - non_mtls_url = "https://storage.googleapis.com/bucket/mtls.googleapis.com" authed_http = google.auth.transport.urllib3.AuthorizedHttp( credentials, http=http ) @@ -688,10 +694,18 @@ def test_cert_rotation_skipped_on_non_mtls_url(self): # Assert mTLS check logic was SKIPPED assert not mock_check_params.called - def test_cert_rotation_triggered_on_psc_url(self): + @pytest.mark.parametrize( + "mtls_url", + [ + "https://storage.p.googleapis.com/b/my-bucket", + "https://my-service-123456.us-central1.mtls.run.app/v1", + ], + ) + def test_cert_rotation_triggered_on_mtls_url(self, mtls_url): """ - Tests that mTLS cert rotation IS triggered on a Private Service Connect - (PSC) mTLS endpoint when an UNAUTHORIZED (401) response is received. + Tests that mTLS cert rotation IS triggered on Private Service Connect + (PSC) and Cloud Run mTLS endpoints when an UNAUTHORIZED (401) response + is received. """ credentials = mock.Mock(wraps=CredentialsStub()) http = HttpStub( @@ -700,7 +714,6 @@ def test_cert_rotation_triggered_on_psc_url(self): ResponseStub(status=http_client.OK), ] ) - psc_url = "https://storage.p.googleapis.com/b/my-bucket" authed_http = google.auth.transport.urllib3.AuthorizedHttp( credentials, http=http ) @@ -712,9 +725,9 @@ def test_cert_rotation_triggered_on_psc_url(self): "check_parameters_for_unauthorized_response", return_value=(b"new_cert", b"new_key", "old_fp", "old_fp"), ) as mock_check_params: - authed_http.urlopen("GET", psc_url) + authed_http.urlopen("GET", mtls_url) - # Assert mTLS check logic was called on PSC endpoint + # Assert mTLS check logic was called on PSC / Cloud Run mTLS endpoint mock_check_params.assert_called_once() assert credentials.refresh.called