From e618bd432fbce9579d5293c7d982267371ab77b3 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 6 Aug 2026 20:07:18 +0000 Subject: [PATCH 1/9] fix(auth): prevent TypeError and support home-dir cert fallback for X.509 WIF on ECP machines - Prevent TypeError crash in identity_pool.py by raising ClientCertError if _get_mtls_cert_and_key_paths() returns None for the certificate path. - Add fallback in _mtls_helper.py to check the default home directory configuration ~/.config/gcloud/certificate_config.json if the env-var-resolved config does not contain a workload block. - Add unit tests to cover both behaviors and verify they function correctly. Fixes: b/542359992 --- .../google-auth/google/auth/identity_pool.py | 4 ++ .../google/auth/transport/_mtls_helper.py | 8 ++++ .../google-auth/tests/test_identity_pool.py | 16 +++++++ .../tests/transport/test__mtls_helper.py | 47 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 4b1aa393b2fa..b32de78add92 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -412,6 +412,10 @@ def _get_mtls_cert_and_key_paths(self): def _get_cert_bytes(self): cert_path, _ = self._get_mtls_cert_and_key_paths() + if cert_path is None: + raise exceptions.ClientCertError( + "Workload certificate configuration could not be found or does not contain workload certificate paths." + ) return _mtls_helper._read_cert_file(cert_path) def _mtls_required(self): diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index eb0600740c0d..3fd27dbaafb5 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -472,6 +472,14 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): # and we want to gracefully fallback to testing other mTLS configurations # like SecureConnect instead of throwing an exception. + if "workload" not in cert_configs and config_path is None: + default_home_path = path.expanduser(CERTIFICATE_CONFIGURATION_DEFAULT_PATH) + if path.exists(default_home_path) and default_home_path != absolute_path: + home_data = _load_json_file(default_home_path) + if "cert_configs" in home_data and "workload" in home_data["cert_configs"]: + cert_configs = home_data["cert_configs"] + absolute_path = default_home_path + if "workload" not in cert_configs: return None, None workload = cert_configs["workload"] diff --git a/packages/google-auth/tests/test_identity_pool.py b/packages/google-auth/tests/test_identity_pool.py index 92659bd90b38..55fa7a177439 100644 --- a/packages/google-auth/tests/test_identity_pool.py +++ b/packages/google-auth/tests/test_identity_pool.py @@ -1784,6 +1784,22 @@ def test_get_mtls_certs_invalid(self): 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' ) + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=(None, None), + ) + def test_get_cert_bytes_none_raises_error(self, mock_get_workload_cert_and_key_paths): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() + ) + + with pytest.raises(exceptions.ClientCertError) as excinfo: + credentials._get_cert_bytes() + + assert excinfo.match( + "Workload certificate configuration could not be found or does not contain workload certificate paths." + ) + @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/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 537ef47e7295..f472784a7098 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -511,6 +511,53 @@ def test_no_workload(self, mock_get_cert_config_path, mock_load_json_file): assert actual_cert is None assert actual_key is None + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + def test_no_workload_fallback_to_home( + self, + mock_path_exists, + mock_read_cert_and_key_files, + mock_get_cert_config_path, + mock_load_json_file, + ): + ecp_path = "/etc/gcloud/certificate_config.json" + home_path = os.path.expanduser("~/.config/gcloud/certificate_config.json") + mock_get_cert_config_path.return_value = ecp_path + + def exists_side_effect(path): + if path == home_path: + return True + return False + + mock_path_exists.side_effect = exists_side_effect + + def load_json_side_effect(path): + if path == ecp_path: + return {"cert_configs": {"pkcs11": {}}} + elif path == home_path: + return { + "cert_configs": { + "workload": {"cert_path": "cert/path", "key_path": "key/path"} + } + } + return {} + + mock_load_json_file.side_effect = load_json_side_effect + mock_read_cert_and_key_files.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key(None) + assert actual_cert == pytest.public_cert_bytes + assert actual_key == pytest.private_key_bytes + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True From 787c25a5cc1e08ffff8ca94a1f78929501706499 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 6 Aug 2026 20:12:24 +0000 Subject: [PATCH 2/9] fix(auth): add defensive dictionary checks for loaded certificate configuration JSON --- .../google/auth/transport/_mtls_helper.py | 14 ++++++++------ .../tests/transport/test__mtls_helper.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 3fd27dbaafb5..b97102cdaed2 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -459,7 +459,7 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): data = _load_json_file(absolute_path) - if "cert_configs" not in data: + if not isinstance(data, dict) or "cert_configs" not in data: raise exceptions.ClientCertError( 'Certificate config file {} is in an invalid format, a "cert configs" object is expected'.format( absolute_path @@ -472,15 +472,17 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): # and we want to gracefully fallback to testing other mTLS configurations # like SecureConnect instead of throwing an exception. - if "workload" not in cert_configs and config_path is None: + if (not isinstance(cert_configs, dict) or "workload" not in cert_configs) and config_path is None: default_home_path = path.expanduser(CERTIFICATE_CONFIGURATION_DEFAULT_PATH) if path.exists(default_home_path) and default_home_path != absolute_path: home_data = _load_json_file(default_home_path) - if "cert_configs" in home_data and "workload" in home_data["cert_configs"]: - cert_configs = home_data["cert_configs"] - absolute_path = default_home_path + if isinstance(home_data, dict): + home_cert_configs = home_data.get("cert_configs") + if isinstance(home_cert_configs, dict) and "workload" in home_cert_configs: + cert_configs = home_cert_configs + absolute_path = default_home_path - if "workload" not in cert_configs: + if not isinstance(cert_configs, dict) or "workload" not in cert_configs: return None, None workload = cert_configs["workload"] diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index f472784a7098..35ca94b7cf30 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -499,6 +499,22 @@ def test_no_cert_configs( 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( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + def test_malformed_json_returns_error( + self, mock_path_exists, mock_get_cert_config_path, mock_load_json_file + ): + mock_path_exists.return_value = True + mock_get_cert_config_path.return_value = "/path/to/cert" + + for val in [None, [], "invalid_string"]: + mock_load_json_file.return_value = val + 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( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True From d23debc485485abb02dd071786e8cb1a82847761 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 10 Aug 2026 14:52:16 +0000 Subject: [PATCH 3/9] fix(auth): construct home folder config path using _cloud_sdk.get_config_path() --- packages/google-auth/google/auth/transport/_mtls_helper.py | 4 +++- packages/google-auth/tests/transport/test__mtls_helper.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index b97102cdaed2..12c4767a9541 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -473,7 +473,9 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): # like SecureConnect instead of throwing an exception. if (not isinstance(cert_configs, dict) or "workload" not in cert_configs) and config_path is None: - default_home_path = path.expanduser(CERTIFICATE_CONFIGURATION_DEFAULT_PATH) + default_home_path = os.path.join( + _cloud_sdk.get_config_path(), "certificate_config.json" + ) if path.exists(default_home_path) and default_home_path != absolute_path: home_data = _load_json_file(default_home_path) if isinstance(home_data, dict): diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 35ca94b7cf30..71dbfc491bf9 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -543,7 +543,9 @@ def test_no_workload_fallback_to_home( mock_load_json_file, ): ecp_path = "/etc/gcloud/certificate_config.json" - home_path = os.path.expanduser("~/.config/gcloud/certificate_config.json") + home_path = os.path.join( + _mtls_helper._cloud_sdk.get_config_path(), "certificate_config.json" + ) mock_get_cert_config_path.return_value = ecp_path def exists_side_effect(path): From 6f7790d63e4b78a0ffe60f7076800b1ad5dcff58 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 10 Aug 2026 15:28:43 +0000 Subject: [PATCH 4/9] fix(auth): verify workload config is a dictionary before key validation --- .../google/auth/transport/_mtls_helper.py | 2 +- .../tests/transport/test__mtls_helper.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 12c4767a9541..0ec920a486ba 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -488,7 +488,7 @@ 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 or "key_path" not in workload: + if not isinstance(workload, dict) or "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 diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 71dbfc491bf9..d2bb31991824 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -515,6 +515,25 @@ def test_malformed_json_returns_error( 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( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + def test_non_dict_workload_raises_error( + self, mock_path_exists, mock_get_cert_config_path, mock_load_json_file + ): + mock_path_exists.return_value = True + mock_get_cert_config_path.return_value = "/path/to/cert" + + for invalid_workload in [None, 123, "not_a_dict"]: + mock_load_json_file.return_value = { + "cert_configs": {"workload": invalid_workload} + } + + 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( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True From 793ca729e37979952f299760478a2400e5e5317d Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 10 Aug 2026 15:40:15 +0000 Subject: [PATCH 5/9] fix(auth): wrap fallback home config file loading in try/except to ignore errors --- .../google/auth/transport/_mtls_helper.py | 15 ++++--- .../tests/transport/test__mtls_helper.py | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 0ec920a486ba..ba10a263abd3 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -477,12 +477,15 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): _cloud_sdk.get_config_path(), "certificate_config.json" ) if path.exists(default_home_path) and default_home_path != absolute_path: - home_data = _load_json_file(default_home_path) - if isinstance(home_data, dict): - home_cert_configs = home_data.get("cert_configs") - if isinstance(home_cert_configs, dict) and "workload" in home_cert_configs: - cert_configs = home_cert_configs - absolute_path = default_home_path + try: + home_data = _load_json_file(default_home_path) + if isinstance(home_data, dict): + home_cert_configs = home_data.get("cert_configs") + if isinstance(home_cert_configs, dict) and "workload" in home_cert_configs: + cert_configs = home_cert_configs + absolute_path = default_home_path + except (exceptions.ClientCertError, OSError): + pass if not isinstance(cert_configs, dict) or "workload" not in cert_configs: return None, None diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index d2bb31991824..4d2935f591d9 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -595,6 +595,47 @@ def load_json_side_effect(path): assert actual_cert == pytest.public_cert_bytes assert actual_key == pytest.private_key_bytes + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + def test_no_workload_fallback_to_home_error( + self, + mock_path_exists, + mock_read_cert_and_key_files, + mock_get_cert_config_path, + mock_load_json_file, + ): + ecp_path = "/etc/gcloud/certificate_config.json" + home_path = os.path.join( + _mtls_helper._cloud_sdk.get_config_path(), "certificate_config.json" + ) + mock_get_cert_config_path.return_value = ecp_path + + def exists_side_effect(path): + if path == home_path: + return True + return False + + mock_path_exists.side_effect = exists_side_effect + + def load_json_side_effect(path): + if path == ecp_path: + return {"cert_configs": {"pkcs11": {}}} + elif path == home_path: + raise exceptions.ClientCertError("mocked unreadable file") + return {} + + mock_load_json_file.side_effect = load_json_side_effect + + actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key(None) + assert actual_cert is None + assert actual_key is None + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True From 9d36f2569403b228cee962e806b98c68aa0949b7 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 10 Aug 2026 15:48:26 +0000 Subject: [PATCH 6/9] fix(auth): verify cert_configs is a dictionary in configuration loading --- .../google/auth/transport/_mtls_helper.py | 2 +- .../tests/transport/test__mtls_helper.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index ba10a263abd3..d422feaf7b63 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -459,7 +459,7 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): data = _load_json_file(absolute_path) - if not isinstance(data, dict) or "cert_configs" not in data: + if not isinstance(data, dict) or "cert_configs" not in data or not isinstance(data["cert_configs"], dict): raise exceptions.ClientCertError( 'Certificate config file {} is in an invalid format, a "cert configs" object is expected'.format( absolute_path diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 4d2935f591d9..1767dec775ac 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -499,6 +499,22 @@ def test_no_cert_configs( 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( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch("os.path.exists", autospec=True) + def test_non_dict_cert_configs_raises_error( + self, mock_path_exists, mock_get_cert_config_path, mock_load_json_file + ): + mock_path_exists.return_value = True + mock_get_cert_config_path.return_value = "/path/to/cert" + + for val in [None, [], "not_a_dict"]: + mock_load_json_file.return_value = {"cert_configs": val} + 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( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True From 4cd5eab1fa881f1af7b9c066110c10cc78653450 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 10 Aug 2026 16:02:51 +0000 Subject: [PATCH 7/9] test(auth): simplify invalid config tests and verify fallback call flows --- .../google/auth/transport/_mtls_helper.py | 21 ++++++++-- .../tests/transport/test__mtls_helper.py | 38 +++++++++---------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index d422feaf7b63..12a04dc14bd4 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -459,7 +459,11 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): data = _load_json_file(absolute_path) - if not isinstance(data, dict) or "cert_configs" not in data or not isinstance(data["cert_configs"], dict): + if ( + not isinstance(data, dict) + or "cert_configs" not in data + or not isinstance(data["cert_configs"], dict) + ): raise exceptions.ClientCertError( 'Certificate config file {} is in an invalid format, a "cert configs" object is expected'.format( absolute_path @@ -472,7 +476,9 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): # and we want to gracefully fallback to testing other mTLS configurations # like SecureConnect instead of throwing an exception. - if (not isinstance(cert_configs, dict) or "workload" not in cert_configs) and config_path is None: + if ( + not isinstance(cert_configs, dict) or "workload" not in cert_configs + ) and config_path is None: default_home_path = os.path.join( _cloud_sdk.get_config_path(), "certificate_config.json" ) @@ -481,7 +487,10 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): home_data = _load_json_file(default_home_path) if isinstance(home_data, dict): home_cert_configs = home_data.get("cert_configs") - if isinstance(home_cert_configs, dict) and "workload" in home_cert_configs: + if ( + isinstance(home_cert_configs, dict) + and "workload" in home_cert_configs + ): cert_configs = home_cert_configs absolute_path = default_home_path except (exceptions.ClientCertError, OSError): @@ -491,7 +500,11 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): return None, None workload = cert_configs["workload"] - if not isinstance(workload, dict) or "cert_path" not in workload or "key_path" not in workload: + if ( + not isinstance(workload, dict) + or "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 diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 1767dec775ac..957d990aa5bc 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -500,47 +500,33 @@ def test_no_cert_configs( _mtls_helper._get_workload_cert_and_key("") @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) - @mock.patch( - "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True - ) @mock.patch("os.path.exists", autospec=True) def test_non_dict_cert_configs_raises_error( - self, mock_path_exists, mock_get_cert_config_path, mock_load_json_file + self, mock_path_exists, mock_load_json_file ): mock_path_exists.return_value = True - mock_get_cert_config_path.return_value = "/path/to/cert" for val in [None, [], "not_a_dict"]: mock_load_json_file.return_value = {"cert_configs": val} with pytest.raises(exceptions.ClientCertError): - _mtls_helper._get_workload_cert_and_key("") + _mtls_helper._get_workload_cert_and_key(None) @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) - @mock.patch( - "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True - ) @mock.patch("os.path.exists", autospec=True) - def test_malformed_json_returns_error( - self, mock_path_exists, mock_get_cert_config_path, mock_load_json_file - ): + def test_malformed_json_returns_error(self, mock_path_exists, mock_load_json_file): mock_path_exists.return_value = True - mock_get_cert_config_path.return_value = "/path/to/cert" for val in [None, [], "invalid_string"]: mock_load_json_file.return_value = val with pytest.raises(exceptions.ClientCertError): - _mtls_helper._get_workload_cert_and_key("") + _mtls_helper._get_workload_cert_and_key(None) @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) - @mock.patch( - "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True - ) @mock.patch("os.path.exists", autospec=True) def test_non_dict_workload_raises_error( - self, mock_path_exists, mock_get_cert_config_path, mock_load_json_file + self, mock_path_exists, mock_load_json_file ): mock_path_exists.return_value = True - mock_get_cert_config_path.return_value = "/path/to/cert" for invalid_workload in [None, 123, "not_a_dict"]: mock_load_json_file.return_value = { @@ -548,7 +534,7 @@ def test_non_dict_workload_raises_error( } with pytest.raises(exceptions.ClientCertError): - _mtls_helper._get_workload_cert_and_key("") + _mtls_helper._get_workload_cert_and_key(None) @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( @@ -611,6 +597,12 @@ def load_json_side_effect(path): assert actual_cert == pytest.public_cert_bytes assert actual_key == pytest.private_key_bytes + mock_get_cert_config_path.assert_called_once_with(None, True) + mock_load_json_file.assert_has_calls( + [mock.call(ecp_path), mock.call(home_path)] + ) + mock_read_cert_and_key_files.assert_called_once_with("cert/path", "key/path") + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True @@ -652,6 +644,12 @@ def load_json_side_effect(path): assert actual_cert is None assert actual_key is None + mock_get_cert_config_path.assert_called_once_with(None, True) + mock_load_json_file.assert_has_calls( + [mock.call(ecp_path), mock.call(home_path)] + ) + mock_read_cert_and_key_files.assert_not_called() + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True From 1c6fe474f53187006a929124e2fc5444d8ba4280 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 10 Aug 2026 16:27:42 +0000 Subject: [PATCH 8/9] fix(auth): wrap ClientCertError in RefreshError during credentials refresh --- .../google-auth/google/auth/identity_pool.py | 7 ++++++- .../google-auth/tests/test_identity_pool.py | 21 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index b32de78add92..bc5027adce02 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -572,7 +572,12 @@ def refresh(self, request): cert_fingerprint = None # Check if the credential is X.509 based. if self._credential_source_certificate is not None: - cert_bytes = self._get_cert_bytes() + try: + cert_bytes = self._get_cert_bytes() + except exceptions.ClientCertError as e: + raise exceptions.RefreshError( + "Failed to retrieve certificate bytes 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 = ( diff --git a/packages/google-auth/tests/test_identity_pool.py b/packages/google-auth/tests/test_identity_pool.py index 55fa7a177439..a9adfc070cf2 100644 --- a/packages/google-auth/tests/test_identity_pool.py +++ b/packages/google-auth/tests/test_identity_pool.py @@ -1788,7 +1788,9 @@ def test_get_mtls_certs_invalid(self): "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", return_value=(None, None), ) - def test_get_cert_bytes_none_raises_error(self, mock_get_workload_cert_and_key_paths): + def test_get_cert_bytes_none_raises_error( + self, mock_get_workload_cert_and_key_paths + ): credentials = self.make_credentials( credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() ) @@ -1800,6 +1802,23 @@ def test_get_cert_bytes_none_raises_error(self, mock_get_workload_cert_and_key_p "Workload certificate configuration could not be found or does not contain workload certificate paths." ) + @mock.patch.object( + identity_pool.Credentials, + "_get_cert_bytes", + side_effect=exceptions.ClientCertError("mock error"), + ) + def test_refresh_cert_error_raises_refresh_error(self, mock_get_cert_bytes): + 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 certificate bytes for external account credentials" + ) + @mock.patch("google.auth._agent_identity_utils.parse_certificate") @mock.patch( "google.auth._agent_identity_utils.should_request_bound_token", From abd54653ab35f0bc9bf8ae0ce043935abc56e9d2 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 10 Aug 2026 23:32:56 +0000 Subject: [PATCH 9/9] fix(auth): handle OSError on refresh and normalize configuration paths on Windows --- GEMINI.md | 20 ++++ .../google-auth/google/auth/identity_pool.py | 5 +- .../google/auth/transport/_mtls_helper.py | 11 +- .../google-auth/tests/test_identity_pool.py | 16 +++ .../tests/transport/test__mtls_helper.py | 112 +++++++++++++++--- 5 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000000..b9f1edb586e9 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,20 @@ +# Google Cloud Python Workspace Rules + +These guidelines are automatically applied to Python development tasks within this repository. + +--- + +## 1. Filesystem and Path Resolution +* **Dynamic Configuration Directories:** Never hardcode paths like `~/.config/gcloud/` or standard user directories. Always utilize existing SDK helpers (such as `_cloud_sdk.get_config_path()`) to dynamically locate system and configuration files. +* **Path Normalization:** When comparing path strings (especially paths retrieved from environment variables or dynamically built), always normalize them using `os.path.normpath` or `pathlib.Path` to prevent Windows vs Unix slash mismatch issues (`\` vs `/`). + +## 2. Input Validation (Defensive Programming) +* **Untrusted File Inputs:** Any data loaded from external configuration files (JSON, YAML, CSV) is untrusted. Always type-validate structure (e.g. check `isinstance(data, dict)` and `isinstance(data.get("sub_key"), dict)`) *before* indexing or calling dictionary lookup keys, avoiding `TypeError` exceptions. + +## 3. Exception Contract Compliance +* **Public Interface Contracts:** When introducing new exception pathways in internal helpers, always trace their propagation. If a public-facing API method (e.g. `refresh()`) is documented to raise a specific base exception class (like `RefreshError`), wrap lower-level custom exceptions (like `ClientCertError`) or system exceptions (like `OSError`) and re-raise them under the correct interface exception types. +* **Self-Contained Fallbacks:** Fallback logic must be resilient and self-contained. Always wrap fallback configuration loading in try-except blocks to catch expected exceptions (like `ClientCertError` or `OSError`) and bypass failures gracefully. + +## 4. Unit Testing and Mock Hygiene +* **Localized Mocking:** When mocking standard functions or filesystem checks (like `path.exists`), mock the local module import path (e.g., `google.auth.transport._mtls_helper.path.exists`) instead of patching builtins globally (e.g., `os.path.exists`), ensuring mocks are isolated. +* **Fallback Verification:** Fallback test cases must explicitly verify execution flow by asserting the expected call sequence and arguments of mocked helpers using `assert_called_once_with` or `assert_has_calls`. diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index bc5027adce02..dd5f103b7250 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -574,9 +574,10 @@ def refresh(self, request): if self._credential_source_certificate is not None: try: cert_bytes = self._get_cert_bytes() - except exceptions.ClientCertError as e: + except (exceptions.ClientCertError, OSError) as e: raise exceptions.RefreshError( - "Failed to retrieve certificate bytes for external account credentials" + "Failed to retrieve certificate bytes for external" + " account credentials" ) from e cert = _agent_identity_utils.parse_certificate(cert_bytes) if _agent_identity_utils.should_request_bound_token(cert): diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 12a04dc14bd4..7779c484c713 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -479,10 +479,15 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): if ( not isinstance(cert_configs, dict) or "workload" not in cert_configs ) and config_path is None: - default_home_path = os.path.join( - _cloud_sdk.get_config_path(), "certificate_config.json" + default_home_path = path.expanduser( + os.path.join( + _cloud_sdk.get_config_path(), + "certificate_config.json", + ) ) - if path.exists(default_home_path) and default_home_path != absolute_path: + if path.exists(default_home_path) and os.path.normpath( + default_home_path + ) != os.path.normpath(absolute_path): try: home_data = _load_json_file(default_home_path) if isinstance(home_data, dict): diff --git a/packages/google-auth/tests/test_identity_pool.py b/packages/google-auth/tests/test_identity_pool.py index a9adfc070cf2..1138db284db7 100644 --- a/packages/google-auth/tests/test_identity_pool.py +++ b/packages/google-auth/tests/test_identity_pool.py @@ -1819,6 +1819,22 @@ def test_refresh_cert_error_raises_refresh_error(self, mock_get_cert_bytes): "Failed to retrieve certificate bytes for external account credentials" ) + @mock.patch.object( + identity_pool.Credentials, + "_get_cert_bytes", + side_effect=OSError("mock os error"), + ) + def test_refresh_os_error_raises_refresh_error(self, mock_get_cert_bytes): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + + msg = "Failed to retrieve certificate bytes for external" + assert excinfo.match(msg + " 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/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 957d990aa5bc..e9bb62db2133 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -500,7 +500,7 @@ def test_no_cert_configs( _mtls_helper._get_workload_cert_and_key("") @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) - @mock.patch("os.path.exists", autospec=True) + @mock.patch("google.auth.transport._mtls_helper.path.exists", autospec=True) def test_non_dict_cert_configs_raises_error( self, mock_path_exists, mock_load_json_file ): @@ -512,7 +512,7 @@ def test_non_dict_cert_configs_raises_error( _mtls_helper._get_workload_cert_and_key(None) @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) - @mock.patch("os.path.exists", autospec=True) + @mock.patch("google.auth.transport._mtls_helper.path.exists", autospec=True) def test_malformed_json_returns_error(self, mock_path_exists, mock_load_json_file): mock_path_exists.return_value = True @@ -522,7 +522,7 @@ def test_malformed_json_returns_error(self, mock_path_exists, mock_load_json_fil _mtls_helper._get_workload_cert_and_key(None) @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) - @mock.patch("os.path.exists", autospec=True) + @mock.patch("google.auth.transport._mtls_helper.path.exists", autospec=True) def test_non_dict_workload_raises_error( self, mock_path_exists, mock_load_json_file ): @@ -548,14 +548,20 @@ def test_no_workload(self, mock_get_cert_config_path, mock_load_json_file): assert actual_cert is None assert actual_key is None - @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( - "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True - ) + "google.auth.transport._mtls_helper._load_json_file", autospec=True + ) # noqa: E501 @mock.patch( - "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True - ) - @mock.patch("os.path.exists", autospec=True) + "google.auth.transport._mtls_helper._get_cert_config_path", + autospec=True, + ) # noqa: E501 + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", + autospec=True, + ) # noqa: E501 + @mock.patch( + "google.auth.transport._mtls_helper.path.exists", autospec=True + ) # noqa: E501 def test_no_workload_fallback_to_home( self, mock_path_exists, @@ -565,7 +571,8 @@ def test_no_workload_fallback_to_home( ): ecp_path = "/etc/gcloud/certificate_config.json" home_path = os.path.join( - _mtls_helper._cloud_sdk.get_config_path(), "certificate_config.json" + _mtls_helper._cloud_sdk.get_config_path(), + "certificate_config.json", ) mock_get_cert_config_path.return_value = ecp_path @@ -582,7 +589,10 @@ def load_json_side_effect(path): elif path == home_path: return { "cert_configs": { - "workload": {"cert_path": "cert/path", "key_path": "key/path"} + "workload": { + "cert_path": "cert/path", + "key_path": "key/path", + } } } return {} @@ -601,16 +611,24 @@ def load_json_side_effect(path): mock_load_json_file.assert_has_calls( [mock.call(ecp_path), mock.call(home_path)] ) - mock_read_cert_and_key_files.assert_called_once_with("cert/path", "key/path") + mock_read_cert_and_key_files.assert_called_once_with( + "cert/path", "key/path" + ) # noqa: E501 - @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( - "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True - ) + "google.auth.transport._mtls_helper._load_json_file", autospec=True + ) # noqa: E501 @mock.patch( - "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True - ) - @mock.patch("os.path.exists", autospec=True) + "google.auth.transport._mtls_helper._get_cert_config_path", + autospec=True, + ) # noqa: E501 + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", + autospec=True, + ) # noqa: E501 + @mock.patch( + "google.auth.transport._mtls_helper.path.exists", autospec=True + ) # noqa: E501 def test_no_workload_fallback_to_home_error( self, mock_path_exists, @@ -620,7 +638,8 @@ def test_no_workload_fallback_to_home_error( ): ecp_path = "/etc/gcloud/certificate_config.json" home_path = os.path.join( - _mtls_helper._cloud_sdk.get_config_path(), "certificate_config.json" + _mtls_helper._cloud_sdk.get_config_path(), + "certificate_config.json", ) mock_get_cert_config_path.return_value = ecp_path @@ -650,6 +669,61 @@ def load_json_side_effect(path): ) mock_read_cert_and_key_files.assert_not_called() + @mock.patch( + "google.auth.transport._mtls_helper._load_json_file", autospec=True + ) # noqa: E501 + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", + autospec=True, + ) + @mock.patch( + "google.auth.transport._mtls_helper.path.exists", autospec=True + ) # noqa: E501 + @mock.patch("os.path.normpath", autospec=True) + def test_no_workload_fallback_avoided_same_path_normalization( + self, + mock_normpath, + mock_path_exists, + mock_get_cert_config_path, + mock_load_json_file, + ): + ecp_path = "C:/Users/User/.config/gcloud/certificate_config.json" + home_path = "C:\\Users\\User\\.config\\gcloud/certificate_config.json" + mock_get_cert_config_path.return_value = ecp_path + + mock_path_exists.return_value = True + + # When resolving, the first file has no workload. + mock_load_json_file.return_value = {"cert_configs": {"pkcs11": {}}} + + win_path = "C:\\Users\\User\\.config\\gcloud\\certificate_config.json" + + # Mock normpath to return the same string for both paths, + # simulating Windows path normalization. + def normpath_side_effect(path): + if path in [ecp_path, home_path]: + return win_path + return path + + mock_normpath.side_effect = normpath_side_effect + + # Mock get_config_path to construct a path with backslashes + with mock.patch( + "google.auth._cloud_sdk.get_config_path", + return_value="C:\\Users\\User\\.config\\gcloud", + ): + actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key( + None + ) # noqa: E501 + + assert actual_cert is None + assert actual_key is None + + # Check that it resolved ECP path but never attempted to load + # home_path (because it normalized to the same file). + mock_get_cert_config_path.assert_called_once_with(None, True) + mock_load_json_file.assert_called_once_with(ecp_path) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True