diff --git a/packages/google-auth/google/auth/_agent_identity_utils.py b/packages/google-auth/google/auth/_agent_identity_utils.py index b57c7bc82b52..33cb901e06a0 100644 --- a/packages/google-auth/google/auth/_agent_identity_utils.py +++ b/packages/google-auth/google/auth/_agent_identity_utils.py @@ -201,6 +201,11 @@ def get_and_parse_agent_identity_certificate(): if is_opted_out: return None + # Respect explicit opt-out of mTLS / client certs + use_client_cert = os.environ.get(environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE) + if use_client_cert and use_client_cert.lower() == "false": + return None + cert_path = get_agent_identity_certificate_path() if not cert_path: return None @@ -312,7 +317,24 @@ def should_request_bound_token(cert): ).lower() == "true" ) - return is_agent_cert and is_opted_in + if not (is_agent_cert and is_opted_in): + return False + + # Respect explicit opt-out of mTLS / client certs + use_client_cert = os.environ.get(environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE) + if use_client_cert and use_client_cert.lower() == "false": + return False + + # Graceful degradation for auto-enablement: + # If GOOGLE_API_USE_CLIENT_CERTIFICATE is not set, we only request a bound token + # if the transport is capable of handling it. + if use_client_cert is None or use_client_cert == "": + from google.auth.transport import _mtls_helper + + if not _mtls_helper.is_transport_mtls_capable(): + return False + + return True 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 027cb09c15a9..7613b22ab9dc 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -150,11 +150,11 @@ def __init__( async def configure_mtls_channel(self, client_cert_callback=None): """Configure the client certificate and key for SSL connection. - The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is - explicitly set to `true`. In this case if client certificate and key are - successfully obtained (from the given client_cert_callback or from application - default SSL credentials), the underlying transport will be reconfigured - to use mTLS. + This method configures mTLS if client certificates are explicitly enabled + (via GOOGLE_API_USE_CLIENT_CERTIFICATE=true) or auto-enabled (when the env + variable is unset and workload certificates are discovered). In these cases, + the underlying transport will be reconfigured to use mTLS. + Note: This function does nothing if the `aiohttp` library is not installed. Important: Calling this method will close any ongoing API requests associated @@ -170,7 +170,8 @@ async def configure_mtls_channel(self, client_cert_callback=None): Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel - creation failed for any reason. + creation failed for any reason (e.g., missing dependencies, custom request + handler limitations, or missing certificates when mTLS was requested). """ if self._mtls_init_task is None: @@ -207,6 +208,20 @@ async def _do_configure(): self._auth_request = AiohttpRequest(session=new_session) await old_auth_request.close() + else: + self._is_mtls = False + # If a custom request handler was provided, the library cannot configure + # the connection to use mTLS. Raise an error instead of silently falling back. + raise exceptions.MutualTLSChannelError( + "mTLS was configured/enabled but client certificate or private key could not be loaded." + ) + else: + # If mTLS is configured or intended, but we fail to find client certificates, + # we must fail fast by raising an error instead of silently falling back to + # standard TLS. + raise exceptions.MutualTLSChannelError( + "mTLS was configured/enabled but client certificate or private key could not be loaded." + ) except ( exceptions.ClientCertError, diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index d6450291c7f2..1841fe297343 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -28,6 +28,9 @@ # Default gcloud config path, to be used with path.expanduser for cross-platform compatibility. CERTIFICATE_CONFIGURATION_DEFAULT_PATH = "~/.config/gcloud/certificate_config.json" +_WELL_KNOWN_SPIFFE_CERT_PATH = ( + "/var/run/secrets/workload-spiffe-credentials/certificates.pem" +) _CERT_PROVIDER_COMMAND = "cert_provider_command" _CERT_REGEX = re.compile( b"-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\r?\n?", re.DOTALL @@ -200,12 +203,13 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): return None, None workload = cert_configs["workload"] - if "cert_path" not in workload: - return None, None + if "cert_path" not in workload or "key_path" not in workload: + raise exceptions.ClientCertError( + 'Workload certificate configuration is missing "cert_path" or "key_path" in {}'.format( + absolute_path + ) + ) cert_path = workload["cert_path"] - - if "key_path" not in workload: - return None, None key_path = workload["key_path"] # == BEGIN Temporary Cloud Run PATCH == @@ -448,6 +452,17 @@ def client_cert_callback(): return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) +def is_transport_mtls_capable(): + """Returns True if the required dependencies for establishing an mTLS connection are present.""" + try: + import OpenSSL + from cryptography import x509 + + return True + except ImportError: + return False + + def check_use_client_cert(): """Returns boolean for whether the client certificate should be used for mTLS. @@ -455,9 +470,13 @@ def check_use_client_cert(): bool value will be returned. If the value is set to an unexpected string, it will default to False. If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred - by reading a file pointed at by GOOGLE_API_CERTIFICATE_CONFIG, and verifying - it contains a "workload" section. If so, the function will return True, - otherwise False. + as True (auto-enabled) if the required transport libraries (pyOpenSSL and cryptography) + are installed AND either: + 1. A workload config file exists (pointed at by GOOGLE_API_CERTIFICATE_CONFIG) + containing a "workload" section. + 2. A certificate file exists at the well-known SPIFFE path + (/var/run/secrets/workload-spiffe-credentials/certificates.pem). + Otherwise, it returns False. Returns: bool: Whether the client certificate should be used for mTLS connection. @@ -471,31 +490,44 @@ def check_use_client_cert(): # Check if the value of GOOGLE_API_USE_CLIENT_CERTIFICATE is set. if use_client_cert: return use_client_cert.lower() == "true" - else: - # Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set. - cert_path = getenv(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG) - if cert_path is None: - cert_path = getenv( - environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH - ) - if cert_path: - try: - with open(cert_path, "r") as f: - content = json.load(f) - # verify json has workload key - content["cert_configs"]["workload"] - return True - except ( - FileNotFoundError, - OSError, - KeyError, - TypeError, - json.JSONDecodeError, - ) as e: - _LOGGER.debug("error decoding certificate: %s", e) + # Auto-enablement checks (when GOOGLE_API_USE_CLIENT_CERTIFICATE is not set) + # Gracefully degrade to standard TLS if transport capabilities are missing + if not is_transport_mtls_capable(): return False + # Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set. + cert_path = getenv(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG) + if cert_path is None: + cert_path = getenv( + environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH + ) + + if cert_path: + try: + with open(cert_path, "r") as f: + content = json.load(f) + # verify json has workload key + content["cert_configs"]["workload"] + return True + except ( + FileNotFoundError, + OSError, + KeyError, + TypeError, + json.JSONDecodeError, + ) as e: + _LOGGER.debug("error decoding certificate: %s", e) + + # Fallback check for well-known SPIFFE path (agent identity) + if ( + path.exists(_WELL_KNOWN_SPIFFE_CERT_PATH) + and path.getsize(_WELL_KNOWN_SPIFFE_CERT_PATH) > 0 + ): + return True + + return False + def check_parameters_for_unauthorized_response(cached_cert): """Returns the cached and current cert fingerprint for reconfiguring mTLS. diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index 9735762c4414..f9634bea67c5 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -428,11 +428,11 @@ def __init__( def configure_mtls_channel(self, client_cert_callback=None): """Configure the client certificate and key for SSL connection. - The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is - explicitly set to `true`. In this case if client certificate and key are - successfully obtained (from the given client_cert_callback or from application - default SSL credentials), a :class:`_MutualTlsAdapter` instance will be mounted - to "https://" prefix. + This method configures mTLS if client certificates are explicitly enabled + (via GOOGLE_API_USE_CLIENT_CERTIFICATE=true) or auto-enabled (when the env + variable is unset and workload certificates are discovered). In these cases, + if the client certificate and key are successfully obtained, a + :class:`_MutualTlsAdapter` instance will be mounted to the "https://" prefix. Args: client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): @@ -443,7 +443,8 @@ def configure_mtls_channel(self, client_cert_callback=None): Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel - creation failed for any reason. + creation failed for any reason (e.g. missing dependencies, missing + certificates when explicitly requested, or custom request adapter issues). """ use_client_cert = google.auth.transport._mtls_helper.check_use_client_cert() if not use_client_cert: @@ -468,6 +469,13 @@ def configure_mtls_channel(self, client_cert_callback=None): mtls_adapter = _MutualTlsAdapter(cert, key) self._cached_cert = cert self.mount("https://", mtls_adapter) + else: + # If mTLS is configured or intended, but we fail to find client certificates, + # we must fail fast by raising an error instead of silently falling back to + # standard TLS. + raise exceptions.MutualTLSChannelError( + "mTLS channel configuration failed because no client certificates were found." + ) except ( exceptions.ClientCertError, ImportError, diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index de07007a946c..70e5b49b9a48 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -313,13 +313,12 @@ def __init__( def configure_mtls_channel(self, client_cert_callback=None): """Configures mutual TLS channel using the given client_cert_callback or - application default SSL credentials. The behavior is controlled by - `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable. - (1) If the environment variable value is `true`, the function returns True - if the channel is mutual TLS and False otherwise. The `http` provided - in the constructor will be overwritten. - (2) If the environment variable is not set or `false`, the function does - nothing and it always return False. + application default SSL credentials. + + The channel is configured if GOOGLE_API_USE_CLIENT_CERTIFICATE is "true", + or if it is unset and workload certificates are detected in the environment. + If client_cert_callback is None, default SSL credentials (workload or SecureConnect) + are loaded. Args: client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): @@ -333,7 +332,8 @@ def configure_mtls_channel(self, client_cert_callback=None): Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel - creation failed for any reason. + creation failed for any reason (e.g., missing dependencies, or missing + certificates when mTLS was explicitly/implicitly requested). """ use_client_cert = transport._mtls_helper.check_use_client_cert() if not use_client_cert: @@ -356,7 +356,12 @@ def configure_mtls_channel(self, client_cert_callback=None): self.http = _make_mutual_tls_http(cert, key) self._cached_cert = cert else: - self.http = _make_default_http() + # If mTLS is configured or intended, but we fail to find client certificates, + # we must fail fast by raising an error instead of silently falling back to + # standard TLS. + raise exceptions.MutualTLSChannelError( + "mTLS channel configuration failed because no client certificates were found." + ) except ( exceptions.ClientCertError, ImportError, diff --git a/packages/google-auth/tests/test_agent_identity_utils.py b/packages/google-auth/tests/test_agent_identity_utils.py index 50a47367b9d7..30f6b6a2327a 100644 --- a/packages/google-auth/tests/test_agent_identity_utils.py +++ b/packages/google-auth/tests/test_agent_identity_utils.py @@ -150,6 +150,45 @@ def test_should_request_bound_token(self, mock_is_agent, monkeypatch): ) assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") + @mock.patch("google.auth.transport._mtls_helper.is_transport_mtls_capable") + def test_should_request_bound_token_explicit_use_client_cert_false( + self, mock_capable, mock_is_agent, monkeypatch + ): + mock_is_agent.return_value = True + mock_capable.return_value = True + monkeypatch.setenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + "false", + ) + assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + + @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") + @mock.patch("google.auth.transport._mtls_helper.is_transport_mtls_capable") + def test_should_request_bound_token_auto_enablement_capable( + self, mock_capable, mock_is_agent, monkeypatch + ): + mock_is_agent.return_value = True + mock_capable.return_value = True + monkeypatch.delenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + raising=False, + ) + assert _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + + @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") + @mock.patch("google.auth.transport._mtls_helper.is_transport_mtls_capable") + def test_should_request_bound_token_auto_enablement_incapable( + self, mock_capable, mock_is_agent, monkeypatch + ): + mock_is_agent.return_value = True + mock_capable.return_value = False + monkeypatch.delenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + raising=False, + ) + assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + def test_get_agent_identity_certificate_path_success(self, tmpdir, monkeypatch): cert_path = tmpdir.join("cert.pem") cert_path.write("cert_content") @@ -439,6 +478,18 @@ def test_get_and_parse_agent_identity_certificate_success( mock_parse_certificate.assert_called_once_with(b"cert_bytes") assert result == mock_parse_certificate.return_value + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_and_parse_agent_identity_certificate_use_client_cert_false( + self, mock_get_path, monkeypatch + ): + monkeypatch.setenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + "false", + ) + result = _agent_identity_utils.get_and_parse_agent_identity_certificate() + assert result 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."): _agent_identity_utils.get_cached_cert_fingerprint(None) diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index 18fdb2e58cf1..07334d16e5f3 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -21,6 +21,7 @@ from google.auth import exceptions from google.auth.aio import credentials +from google.auth.aio import transport from google.auth.aio.transport import sessions # This is the valid "workload" format the library expects @@ -76,10 +77,8 @@ async def test_configure_mtls_channel_disabled(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) session = sessions.AsyncAuthorizedSession(mock_creds) - await session.configure_mtls_channel() - - # If the file doesn't exist, it shouldn't error; it just won't use mTLS - assert session._is_mtls is False + with pytest.raises(exceptions.MutualTLSChannelError): + await session.configure_mtls_channel() @pytest.mark.asyncio async def test_configure_mtls_channel_invalid_format(self): @@ -112,10 +111,8 @@ async def test_configure_mtls_channel_invalud_fields(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) session = sessions.AsyncAuthorizedSession(mock_creds) - await session.configure_mtls_channel() - - # If the file couldn't be parsed, it shouldn't error; it just won't use mTLS - assert session._is_mtls is False + with pytest.raises(exceptions.MutualTLSChannelError): + await session.configure_mtls_channel() @pytest.mark.asyncio async def test_configure_mtls_channel_mock_callback(self): @@ -140,3 +137,32 @@ def mock_callback(): await session.configure_mtls_channel(client_cert_callback=mock_callback) assert session._is_mtls is True + + @pytest.mark.asyncio + async def test_configure_mtls_channel_custom_request(self): + """ + Tests that if _auth_request is not an AiohttpRequest, a MutualTLSChannelError is raised. + """ + with mock.patch.dict( + os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"} + ), mock.patch("os.path.exists") as mock_exists, mock.patch( + "builtins.open", mock.mock_open(read_data=json.dumps(VALID_WORKLOAD_CONFIG)) + ), mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key" + ) as mock_helper, mock.patch( + "google.auth.aio.transport.mtls.make_client_cert_ssl_context" + ) as mock_make_context: + mock_exists.return_value = True + mock_helper.return_value = (True, b"fake_cert_data", b"fake_key_data") + + mock_context = mock.Mock(spec=ssl.SSLContext) + mock_make_context.return_value = mock_context + + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_auth_request = mock.AsyncMock(spec=transport.Request) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_request + ) + + with pytest.raises(exceptions.MutualTLSChannelError): + await session.configure_mtls_channel() diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 078df67470d2..20d03973ec38 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -591,9 +591,8 @@ def test_no_cert_file(self, mock_get_cert_config_path, mock_load_json_file): "cert_configs": {"workload": {"key_path": "path/to/key"}} } - actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key("") - assert actual_cert is None - assert actual_key is None + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( @@ -605,9 +604,8 @@ def test_no_key_file(self, mock_get_cert_config_path, mock_load_json_file): "cert_configs": {"workload": {"cert_path": "path/to/key"}} } - actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key("") - assert actual_cert is None - assert actual_key is None + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") class TestReadCertAndKeyFile(object): @@ -913,6 +911,52 @@ def test_check_use_client_cert_config_fallback(self, mock_file): assert _mtls_helper.check_use_client_cert() is True + @mock.patch( + "google.auth.transport._mtls_helper.is_transport_mtls_capable", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + @mock.patch("os.path.getsize", autospec=True) + @mock.patch.dict(os.environ, {}, clear=True) + def test_well_known_path_success(self, mock_getsize, mock_exists, mock_capable): + mock_capable.return_value = True + mock_exists.side_effect = ( + lambda p: p == _mtls_helper._WELL_KNOWN_SPIFFE_CERT_PATH + ) + mock_getsize.return_value = 100 + assert _mtls_helper.check_use_client_cert() is True + + @mock.patch( + "google.auth.transport._mtls_helper.is_transport_mtls_capable", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + @mock.patch("os.path.getsize", autospec=True) + @mock.patch.dict(os.environ, {}, clear=True) + def test_well_known_path_incapable(self, mock_getsize, mock_exists, mock_capable): + mock_capable.return_value = False + mock_exists.side_effect = ( + lambda p: p == _mtls_helper._WELL_KNOWN_SPIFFE_CERT_PATH + ) + mock_getsize.return_value = 100 + assert _mtls_helper.check_use_client_cert() is False + + @mock.patch( + "google.auth.transport._mtls_helper.is_transport_mtls_capable", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + @mock.patch("os.path.getsize", autospec=True) + @mock.patch.dict( + os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false"}, clear=True + ) + def test_explicit_opt_out_with_well_known_path( + self, mock_getsize, mock_exists, mock_capable + ): + mock_capable.return_value = True + mock_exists.side_effect = ( + lambda p: p == _mtls_helper._WELL_KNOWN_SPIFFE_CERT_PATH + ) + mock_getsize.return_value = 100 + assert _mtls_helper.check_use_client_cert() is False + class TestMtlsHelper: @mock.patch.object(_mtls_helper, "call_client_cert_callback") diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index c9fab036e17b..9378135180e6 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -466,15 +466,11 @@ def test_configure_mtls_channel_non_mtls( auth_session = google.auth.transport.requests.AuthorizedSession( credentials=mock.Mock() ) - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} - ): - auth_session.configure_mtls_channel() - - assert not auth_session.is_mtls - - # Assert _MutualTlsAdapter constructor is not called. - mock_adapter_ctor.assert_not_called() + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index b29e4e950433..8c538c4fce52 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -256,14 +256,11 @@ def test_configure_mtls_channel_non_mtls( ) mock_get_client_cert_and_key.return_value = (False, None, None) - with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} - ): - is_mtls = authed_http.configure_mtls_channel() - - assert not is_mtls - mock_get_client_cert_and_key.assert_called_once() - mock_make_mutual_tls_http.assert_not_called() + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + authed_http.configure_mtls_channel() @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True