From 51f8f5abf8f367f8a30aa02337801ad81e8ba80c Mon Sep 17 00:00:00 2001 From: aeitzman Date: Thu, 20 Jun 2024 14:04:18 -0700 Subject: [PATCH 1/9] feat: adds support for X509 workload credential type --- google/auth/external_account.py | 18 ++- google/auth/identity_pool.py | 131 ++++++++++++++++----- google/auth/transport/_mtls_helper.py | 75 +++++++----- tests/test_external_account.py | 161 +++++++++++++++++++++++++- tests/test_identity_pool.py | 129 ++++++++++++++++++++- 5 files changed, 446 insertions(+), 68 deletions(-) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 3943de2a3..7c452be33 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -31,6 +31,7 @@ import copy from dataclasses import dataclass import datetime +import functools import io import json import re @@ -40,6 +41,7 @@ from google.auth import exceptions from google.auth import impersonated_credentials from google.auth import metrics +from google.auth.transport.requests import _MutualTlsAdapter from google.oauth2 import sts from google.oauth2 import utils @@ -393,12 +395,18 @@ def get_project_id(self, request): @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): scopes = self._scopes if self._scopes is not None else self._default_scopes + auth_request = request + + # if mtls is required, wrap the incoming request in a partial to set the cert. + if self._should_add_mtls(): + print("mtls yeah") + auth_request = functools.partial(request, cert=self._get_mtls_cert()) if self._should_initialize_impersonated_credentials(): self._impersonated_credentials = self._initialize_impersonated_credentials() if self._impersonated_credentials: - self._impersonated_credentials.refresh(request) + self._impersonated_credentials.refresh(auth_request) self.token = self._impersonated_credentials.token self.expiry = self._impersonated_credentials.expiry else: @@ -414,7 +422,7 @@ def refresh(self, request): ) } response_data = self._sts_client.exchange_token( - request=request, + request=auth_request, grant_type=_STS_GRANT_TYPE, subject_token=self.retrieve_subject_token(request), subject_token_type=self._subject_token_type, @@ -523,6 +531,12 @@ def _create_default_metrics_options(self): return metrics_options + def _should_add_mtls(self): + return False + + def _get_mtls_cert(self): + raise NotImplementedError("_get_mtls_cert must be implemented.") + @classmethod def from_info(cls, info, **kwargs): """Creates a Credentials instance from parsed external account info. diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 1c97885a4..46723f01f 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -48,6 +48,7 @@ from google.auth import _helpers from google.auth import exceptions from google.auth import external_account +from google.auth.transport import _mtls_helper class SubjectTokenSupplier(metaclass=abc.ABCMeta): @@ -141,6 +142,14 @@ def get_subject_token(self, context, request): ) +class _X509Supplier(SubjectTokenSupplier): + """ Internal implementation of subject token supplier for X509 workload credentials, always returns an empty string.""" + + @_helpers.copy_docstring(SubjectTokenSupplier) + def get_subject_token(self, context, request): + return "" + + def _parse_token_data(token_content, format_type="text", subject_token_field_name=None): if format_type == "text": token = token_content.content @@ -247,6 +256,7 @@ def __init__( self._subject_token_supplier = subject_token_supplier self._credential_source_file = None self._credential_source_url = None + self._credential_source_certificate = None else: if not isinstance(credential_source, Mapping): self._credential_source_executable = None @@ -255,59 +265,61 @@ def __init__( ) self._credential_source_file = credential_source.get("file") self._credential_source_url = credential_source.get("url") - self._credential_source_headers = credential_source.get("headers") - credential_source_format = credential_source.get("format", {}) - # Get credential_source format type. When not provided, this - # defaults to text. - self._credential_source_format_type = ( - credential_source_format.get("type") or "text" - ) + self._credential_source_certificate = credential_source.get("certificate") + # environment_id is only supported in AWS or dedicated future external # account credentials. if "environment_id" in credential_source: raise exceptions.MalformedError( "Invalid Identity Pool credential_source field 'environment_id'" ) - if self._credential_source_format_type not in ["text", "json"]: - raise exceptions.MalformedError( - "Invalid credential_source format '{}'".format( - self._credential_source_format_type + + # check that only one of file, url, or certificate are provided. + if ( + sum( + map( + bool, + [ + self._credential_source_file, + self._credential_source_url, + self._credential_source_certificate, + ], ) ) - # For JSON types, get the required subject_token field name. - if self._credential_source_format_type == "json": - self._credential_source_field_name = credential_source_format.get( - "subject_token_field_name" - ) - if self._credential_source_field_name is None: - raise exceptions.MalformedError( - "Missing subject_token_field_name for JSON credential_source format" - ) - else: - self._credential_source_field_name = None - - if self._credential_source_file and self._credential_source_url: + > 1 + ): raise exceptions.MalformedError( - "Ambiguous credential_source. 'file' is mutually exclusive with 'url'." + "Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.." ) - if not self._credential_source_file and not self._credential_source_url: + if ( + not self._credential_source_file + and not self._credential_source_url + and not self._credential_source_certificate + ): raise exceptions.MalformedError( - "Missing credential_source. A 'file' or 'url' must be provided." + "Missing credential_source. A 'file', 'url', or 'certificate' must be provided." ) + if self._credential_source_certificate: + self._validate_certificate_credential_source() + else: + self._validate_file_url_credential_source(credential_source) + if self._credential_source_file: self._subject_token_supplier = _FileSupplier( self._credential_source_file, self._credential_source_format_type, self._credential_source_field_name, ) - else: + elif self._credential_source_url: self._subject_token_supplier = _UrlSupplier( self._credential_source_url, self._credential_source_format_type, self._credential_source_field_name, self._credential_source_headers, ) + else: + self._subject_token_supplier = _X509Supplier() @_helpers.copy_docstring(external_account.Credentials) def retrieve_subject_token(self, request): @@ -315,16 +327,31 @@ def retrieve_subject_token(self, request): self._supplier_context, request ) + def _get_mtls_cert(self): + if self._credential_source_certificate == None: + raise exceptions.RefreshError( + 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' + ) + else: + return _mtls_helper._get_workload_cert_and_key_paths( + self._certificate_config_location + ) + + def _should_add_mtls(self): + return self._credential_source_certificate is not None + def _create_default_metrics_options(self): metrics_options = super(Credentials, self)._create_default_metrics_options() - # Check that credential source is a dict before checking for file vs url. This check needs to be done + # Check that credential source is a dict before checking for credential type. This check needs to be done # here because the external_account credential constructor needs to pass the metrics options to the # impersonated credential object before the identity_pool credentials are validated. if isinstance(self._credential_source, Mapping): if self._credential_source.get("file"): metrics_options["source"] = "file" - else: + elif self._credential_source.get("url"): metrics_options["source"] = "url" + else: + metrics_options["source"] = "x509" else: metrics_options["source"] = "programmatic" return metrics_options @@ -339,6 +366,50 @@ def _constructor_args(self): args.update({"subject_token_supplier": self._subject_token_supplier}) return args + def _validate_certificate_credential_source(self): + self._certificate_config_location = self._credential_source_certificate.get( + "certificate_config_location" + ) + use_default = self._credential_source_certificate.get( + "use_default_certificate_config" + ) + if self._certificate_config_location: + if use_default: + raise exceptions.MalformedError( + "Invalid certificate configuration, certificate_config_location cannot be specified when use_default_certificate_config = true." + ) + else: + if not use_default: + raise exceptions.MalformedError( + "Invalid certificate configuration, use_default_certificate_config should be true if no certificate_config_location is provided." + ) + + def _validate_file_url_credential_source(self, credential_source): + self._credential_source_headers = credential_source.get("headers") + credential_source_format = credential_source.get("format", {}) + # Get credential_source format type. When not provided, this + # defaults to text. + self._credential_source_format_type = ( + credential_source_format.get("type") or "text" + ) + if self._credential_source_format_type not in ["text", "json"]: + raise exceptions.MalformedError( + "Invalid credential_source format '{}'".format( + self._credential_source_format_type + ) + ) + # For JSON types, get the required subject_token field name. + if self._credential_source_format_type == "json": + self._credential_source_field_name = credential_source_format.get( + "subject_token_field_name" + ) + if self._credential_source_field_name is None: + raise exceptions.MalformedError( + "Missing subject_token_field_name for JSON credential_source format" + ) + else: + self._credential_source_field_name = None + @classmethod def from_info(cls, info, **kwargs): """Creates an Identity Pool Credentials instance from parsed external account info. diff --git a/google/auth/transport/_mtls_helper.py b/google/auth/transport/_mtls_helper.py index e95b953a1..b69ef72ee 100644 --- a/google/auth/transport/_mtls_helper.py +++ b/google/auth/transport/_mtls_helper.py @@ -105,9 +105,50 @@ def _get_workload_cert_and_key(certificate_config_path=None): google.auth.exceptions.ClientCertError: if problems occurs when retrieving the certificate or key information. """ - absolute_path = _get_cert_config_path(certificate_config_path) + + cert_path, key_path = _get_workload_cert_and_key_paths(certificate_config_path) + + if cert_path is None and key_path is None: + return None, None + + return _read_cert_and_key_files(cert_path, key_path) + + +def _get_cert_config_path(certificate_config_path=None): + """Gets the certificate configuration full path using the following order of precedence: + + 1: Explicit override, if set + 2: Environment variable, if set + 3: Well-known location + + Returns "None" if the selected config file does not exist. + + Args: + certificate_config_path (string): The certificate config path. If provided, the well known + location and environment variable will be ignored. + + Returns: + The absolute path of the certificate config file, and None if the file does not exist. + """ + + if certificate_config_path is None: + env_path = environ.get(_CERTIFICATE_CONFIGURATION_ENV, None) + if env_path is not None and env_path != "": + certificate_config_path = env_path + else: + certificate_config_path = _CERTIFICATE_CONFIGURATION_DEFAULT_PATH + + certificate_config_path = path.expanduser(certificate_config_path) + if not path.exists(certificate_config_path): + return None + return certificate_config_path + + +def _get_workload_cert_and_key_paths(config_path): + absolute_path = _get_cert_config_path(config_path) if absolute_path is None: return None, None + data = _load_json_file(absolute_path) if "cert_configs" not in data: @@ -142,37 +183,7 @@ def _get_workload_cert_and_key(certificate_config_path=None): ) key_path = workload["key_path"] - return _read_cert_and_key_files(cert_path, key_path) - - -def _get_cert_config_path(certificate_config_path=None): - """Gets the certificate configuration full path using the following order of precedence: - - 1: Explicit override, if set - 2: Environment variable, if set - 3: Well-known location - - Returns "None" if the selected config file does not exist. - - Args: - certificate_config_path (string): The certificate config path. If provided, the well known - location and environment variable will be ignored. - - Returns: - The absolute path of the certificate config file, and None if the file does not exist. - """ - - if certificate_config_path is None: - env_path = environ.get(_CERTIFICATE_CONFIGURATION_ENV, None) - if env_path is not None and env_path != "": - certificate_config_path = env_path - else: - certificate_config_path = _CERTIFICATE_CONFIGURATION_DEFAULT_PATH - - certificate_config_path = path.expanduser(certificate_config_path) - if not path.exists(certificate_config_path): - return None - return certificate_config_path + return cert_path, key_path def _read_cert_and_key_files(cert_path, key_path): diff --git a/tests/test_external_account.py b/tests/test_external_account.py index c458b21b6..5a5dc6ffe 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -235,10 +235,16 @@ def make_mock_request( return request @classmethod - def assert_token_request_kwargs(cls, request_kwargs, headers, request_data): + def assert_token_request_kwargs( + cls, request_kwargs, headers, request_data, cert=None + ): assert request_kwargs["url"] == cls.TOKEN_URL assert request_kwargs["method"] == "POST" assert request_kwargs["headers"] == headers + if cert != None: + assert request_kwargs["cert"] == cert + else: + assert "cert" not in request_kwargs assert request_kwargs["body"] is not None body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) for (k, v) in body_tuples: @@ -246,10 +252,16 @@ def assert_token_request_kwargs(cls, request_kwargs, headers, request_data): assert len(body_tuples) == len(request_data.keys()) @classmethod - def assert_impersonation_request_kwargs(cls, request_kwargs, headers, request_data): + def assert_impersonation_request_kwargs( + cls, request_kwargs, headers, request_data, cert=None + ): assert request_kwargs["url"] == cls.SERVICE_ACCOUNT_IMPERSONATION_URL assert request_kwargs["method"] == "POST" assert request_kwargs["headers"] == headers + if cert != None: + assert request_kwargs["cert"] == cert + else: + assert "cert" not in request_kwargs assert request_kwargs["body"] is not None body_json = json.loads(request_kwargs["body"].decode("utf-8")) assert body_json == request_data @@ -665,6 +677,56 @@ def test_refresh_without_client_auth_success( assert not credentials.expired assert credentials.token == response["access_token"] + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + @mock.patch( + "google.auth.external_account.Credentials._should_add_mtls", return_value=True + ) + @mock.patch( + "google.auth.external_account.Credentials._get_mtls_cert", + return_value=("test1", "test2"), + ) + def test_refresh_with_mtls( + self, + mock_get_mtls_cert, + mock_should_add_mtls, + unused_utcnow, + mock_auth_lib_value, + ): + response = self.SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["expires_in"] + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/false config-lifetime/false", + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request(status=http_client.OK, data=response) + credentials = self.make_credentials() + + credentials.refresh(request) + + expected_cert_path = ("test1", "test2") + self.assert_token_request_kwargs( + request.call_args[1], headers, request_data, expected_cert_path + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == response["access_token"] + @mock.patch( "google.auth.metrics.python_and_auth_lib_version", return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, @@ -869,6 +931,101 @@ def test_refresh_impersonation_without_client_auth_success( assert not credentials.expired assert credentials.token == impersonation_response["accessToken"] + @mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.metrics.python_and_auth_lib_version", + return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, + ) + @mock.patch( + "google.auth.external_account.Credentials._should_add_mtls", return_value=True + ) + @mock.patch( + "google.auth.external_account.Credentials._get_mtls_cert", + return_value=("test1", "test2"), + ) + def test_refresh_impersonation_with_mtls_success( + self, + mock_get_mtls_cert, + mock_should_add_mtls, + mock_metrics_header_value, + mock_auth_lib_value, + ): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "x-goog-api-client": "gl-python/3.7 auth/1.1 google-byoid-sdk sa-impersonation/true config-lifetime/false", + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + "x-goog-api-client": IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + "x-allowed-locations": "0x0", + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=self.SCOPES, + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + expected_cert_paths = ("test1", "test2") + self.assert_token_request_kwargs( + request.call_args_list[0][1], + token_headers, + token_request_data, + expected_cert_paths, + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + expected_cert_paths, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + @mock.patch( "google.auth.metrics.token_request_access_token_impersonate", return_value=IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index a11b1e70f..1dc52cc3c 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -179,6 +179,12 @@ class TestCredentials(object): "url": CREDENTIAL_URL, "format": {"type": "json", "subject_token_field_name": "access_token"}, } + CREDENTIAL_SOURCE_CERTIFICATE = { + "certificate": {"use_default_certificate_config": "true"} + } + CREDENTIAL_SOURCE_CERTIFICATE_NOT_DEFAULT = { + "certificate": {"certificate_config_location": "path/to/config"} + } SUCCESS_RESPONSE = { "access_token": "ACCESS_TOKEN", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", @@ -677,6 +683,40 @@ def test_constructor_invalid_options_url_and_file(self): assert excinfo.match(r"Ambiguous credential_source") + def test_constructor_invalid_options_url_and_certificate(self): + credential_source = { + "url": self.CREDENTIAL_URL, + "certificate": {"certificate": {"use_default_certificate_config": True}}, + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Ambiguous credential_source") + + def test_constructor_invalid_options_file_and_certificate(self): + credential_source = { + "file": SUBJECT_TOKEN_TEXT_FILE, + "certificate": {"certificate": {"use_default_certificate": True}}, + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Ambiguous credential_source") + + def test_constructor_invalid_options_url_file_and_certificate(self): + credential_source = { + "file": SUBJECT_TOKEN_TEXT_FILE, + "url": self.CREDENTIAL_URL, + "certificate": {"certificate": {"use_default_certificate": True}}, + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Ambiguous credential_source") + def test_constructor_invalid_options_environment_id(self): credential_source = {"url": self.CREDENTIAL_URL, "environment_id": "aws1"} @@ -716,7 +756,7 @@ def test_constructor_invalid_both_credential_source_and_supplier(self): ) def test_constructor_invalid_credential_source_format_type(self): - credential_source = {"format": {"type": "xml"}} + credential_source = {"file": "test.txt", "format": {"type": "xml"}} with pytest.raises(ValueError) as excinfo: self.make_credentials(credential_source=credential_source) @@ -724,7 +764,7 @@ def test_constructor_invalid_credential_source_format_type(self): assert excinfo.match(r"Invalid credential_source format 'xml'") def test_constructor_missing_subject_token_field_name(self): - credential_source = {"format": {"type": "json"}} + credential_source = {"file": "test.txt", "format": {"type": "json"}} with pytest.raises(ValueError) as excinfo: self.make_credentials(credential_source=credential_source) @@ -733,6 +773,27 @@ def test_constructor_missing_subject_token_field_name(self): r"Missing subject_token_field_name for JSON credential_source format" ) + def test_constructor_default_and_file_location_certificate(self): + credential_source = { + "certificate": { + "use_default_certificate_config": True, + "certificate_config_location": "test", + } + } + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Invalid certificate configuration") + + def test_constructor_no_default_or_file_location_certificate(self): + credential_source = {"certificate": {"use_default_certificate_config": False}} + + with pytest.raises(ValueError) as excinfo: + self.make_credentials(credential_source=credential_source) + + assert excinfo.match(r"Invalid certificate configuration") + def test_info_with_workforce_pool_user_project(self): credentials = self.make_credentials( audience=WORKFORCE_AUDIENCE, @@ -782,6 +843,36 @@ def test_info_with_url_credential_source(self): "universe_domain": DEFAULT_UNIVERSE_DOMAIN, } + def test_info_with_certificate_credential_source(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE_CERTIFICATE, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_non_default_certificate_credential_source(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE_NOT_DEFAULT.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "token_info_url": TOKEN_INFO_URL, + "credential_source": self.CREDENTIAL_SOURCE_CERTIFICATE_NOT_DEFAULT, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + def test_info_with_default_token_url(self): credentials = identity_pool.Credentials( audience=AUDIENCE, @@ -845,6 +936,15 @@ def test_retrieve_subject_token_json_file(self): assert subject_token == JSON_FILE_SUBJECT_TOKEN + def test_retrieve_subject_token_certificate(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == "" + def test_retrieve_subject_token_json_file_invalid_field_name(self): credential_source = { "file": SUBJECT_TOKEN_JSON_FILE, @@ -1485,3 +1585,28 @@ def test_refresh_success_supplier_without_impersonation_url(self): scopes=SCOPES, default_scopes=None, ) + + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key_paths", + return_value=("cert", "key"), + ) + def test_get_mtls_certs(self, mock_get_workload_cert_and_key_paths): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() + ) + + cert, key = credentials._get_mtls_cert() + assert cert == "cert" + assert key == "key" + + def test_get_mtls_certs_invalid(self): + credentials = self.make_credentials( + credential_source=self.CREDENTIAL_SOURCE_TEXT.copy() + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials._get_mtls_cert() + + assert excinfo.match( + 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' + ) From fb760d7a88547b643b26aec2ce1777a16297542a Mon Sep 17 00:00:00 2001 From: aeitzman Date: Mon, 24 Jun 2024 12:00:09 -0700 Subject: [PATCH 2/9] fix: PR comments --- google/auth/external_account.py | 2 -- google/auth/identity_pool.py | 51 +++++++++++++++++---------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 7c452be33..05dd55829 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -41,7 +41,6 @@ from google.auth import exceptions from google.auth import impersonated_credentials from google.auth import metrics -from google.auth.transport.requests import _MutualTlsAdapter from google.oauth2 import sts from google.oauth2 import utils @@ -399,7 +398,6 @@ def refresh(self, request): # if mtls is required, wrap the incoming request in a partial to set the cert. if self._should_add_mtls(): - print("mtls yeah") auth_request = functools.partial(request, cert=self._get_mtls_cert()) if self._should_initialize_impersonated_credentials(): diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 46723f01f..2ddfd69b0 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -275,30 +275,7 @@ def __init__( ) # check that only one of file, url, or certificate are provided. - if ( - sum( - map( - bool, - [ - self._credential_source_file, - self._credential_source_url, - self._credential_source_certificate, - ], - ) - ) - > 1 - ): - raise exceptions.MalformedError( - "Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.." - ) - if ( - not self._credential_source_file - and not self._credential_source_url - and not self._credential_source_certificate - ): - raise exceptions.MalformedError( - "Missing credential_source. A 'file', 'url', or 'certificate' must be provided." - ) + self._validate_only_one_source() if self._credential_source_certificate: self._validate_certificate_credential_source() @@ -410,6 +387,32 @@ def _validate_file_url_credential_source(self, credential_source): else: self._credential_source_field_name = None + def _validate_only_one_source(self): + if ( + sum( + map( + bool, + [ + self._credential_source_file, + self._credential_source_url, + self._credential_source_certificate, + ], + ) + ) + > 1 + ): + raise exceptions.MalformedError( + "Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.." + ) + if ( + not self._credential_source_file + and not self._credential_source_url + and not self._credential_source_certificate + ): + raise exceptions.MalformedError( + "Missing credential_source. A 'file', 'url', or 'certificate' must be provided." + ) + @classmethod def from_info(cls, info, **kwargs): """Creates an Identity Pool Credentials instance from parsed external account info. From 7e7bffac357c3f1c22177d6fc2c614add267d38d Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Wed, 26 Jun 2024 10:04:13 -0700 Subject: [PATCH 3/9] Apply suggestions from code review Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> --- google/auth/external_account.py | 2 +- google/auth/identity_pool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 05dd55829..9f62d19ff 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -396,7 +396,7 @@ def refresh(self, request): scopes = self._scopes if self._scopes is not None else self._default_scopes auth_request = request - # if mtls is required, wrap the incoming request in a partial to set the cert. + # If mtls is required, wrap the incoming request in a partial to set the cert. if self._should_add_mtls(): auth_request = functools.partial(request, cert=self._get_mtls_cert()) diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 2ddfd69b0..43320aba6 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -143,7 +143,7 @@ def get_subject_token(self, context, request): class _X509Supplier(SubjectTokenSupplier): - """ Internal implementation of subject token supplier for X509 workload credentials, always returns an empty string.""" + """Internal supplier for X509 workload credentials. This class is used internally and always returns an empty string as the subject token.""" @_helpers.copy_docstring(SubjectTokenSupplier) def get_subject_token(self, context, request): From e2ab193a8267c12bfdea89ad03aad6da0bbd941f Mon Sep 17 00:00:00 2001 From: aeitzman Date: Wed, 26 Jun 2024 10:23:18 -0700 Subject: [PATCH 4/9] fix: responding to PR comments --- google/auth/external_account.py | 25 +++++++++++++++++++++++-- google/auth/identity_pool.py | 4 ++-- tests/test_external_account.py | 20 ++++++++++---------- tests/test_identity_pool.py | 4 ++-- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 9f62d19ff..ef43b5ab8 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -398,7 +398,9 @@ def refresh(self, request): # If mtls is required, wrap the incoming request in a partial to set the cert. if self._should_add_mtls(): - auth_request = functools.partial(request, cert=self._get_mtls_cert()) + auth_request = functools.partial( + request, cert=self._get_mtls_cert_and_key_location() + ) if self._should_initialize_impersonated_credentials(): self._impersonated_credentials = self._initialize_impersonated_credentials() @@ -530,9 +532,28 @@ def _create_default_metrics_options(self): return metrics_options def _should_add_mtls(self): + """Returns a boolean representing whether the current credential is configured + for mTLS and should add a certificate to the outgoing calls to the sts and service + account impersonation endpoint. + + Returns: + bool: True if the credential is configured for mTLS, False if it is not. + """ return False - def _get_mtls_cert(self): + def _get_mtls_cert_and_key_location(self): + """Gets the file locations for a certificate and private key file + to be used for configuring mTLS for the sts and service account + impersonation calls. Currently only expected to return a value when using + X509 workload identity federation. + + Returns: + Tuple[str, str]: The cert and key file locations as strings in a tuple. + + Raises: + NotImplementedError: When the current credential is not configured for + mTLS. + """ raise NotImplementedError("_get_mtls_cert must be implemented.") @classmethod diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 43320aba6..3330940a6 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -304,8 +304,8 @@ def retrieve_subject_token(self, request): self._supplier_context, request ) - def _get_mtls_cert(self): - if self._credential_source_certificate == None: + def _get_mtls_cert_and_key_location(self): + if self._credential_source_certificate is None: raise exceptions.RefreshError( 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' ) diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 5a5dc6ffe..d6f656d9a 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -241,7 +241,7 @@ def assert_token_request_kwargs( assert request_kwargs["url"] == cls.TOKEN_URL assert request_kwargs["method"] == "POST" assert request_kwargs["headers"] == headers - if cert != None: + if cert is not None: assert request_kwargs["cert"] == cert else: assert "cert" not in request_kwargs @@ -258,7 +258,7 @@ def assert_impersonation_request_kwargs( assert request_kwargs["url"] == cls.SERVICE_ACCOUNT_IMPERSONATION_URL assert request_kwargs["method"] == "POST" assert request_kwargs["headers"] == headers - if cert != None: + if cert is not None: assert request_kwargs["cert"] == cert else: assert "cert" not in request_kwargs @@ -686,12 +686,12 @@ def test_refresh_without_client_auth_success( "google.auth.external_account.Credentials._should_add_mtls", return_value=True ) @mock.patch( - "google.auth.external_account.Credentials._get_mtls_cert", - return_value=("test1", "test2"), + "google.auth.external_account.Credentials._get_mtls_cert_and_key_location", + return_value=("path/to/cert.pem", "path/to/key.pem"), ) def test_refresh_with_mtls( self, - mock_get_mtls_cert, + mock_get_mtls_cert_and_key_location, mock_should_add_mtls, unused_utcnow, mock_auth_lib_value, @@ -718,7 +718,7 @@ def test_refresh_with_mtls( credentials.refresh(request) - expected_cert_path = ("test1", "test2") + expected_cert_path = ("path/to/cert.pem", "path/to/key.pem") self.assert_token_request_kwargs( request.call_args[1], headers, request_data, expected_cert_path ) @@ -943,12 +943,12 @@ def test_refresh_impersonation_without_client_auth_success( "google.auth.external_account.Credentials._should_add_mtls", return_value=True ) @mock.patch( - "google.auth.external_account.Credentials._get_mtls_cert", - return_value=("test1", "test2"), + "google.auth.external_account.Credentials._get_mtls_cert_and_key_location", + return_value=("path/to/cert.pem", "path/to/key.pem"), ) def test_refresh_impersonation_with_mtls_success( self, - mock_get_mtls_cert, + mock_get_mtls_cert_and_key_location, mock_should_add_mtls, mock_metrics_header_value, mock_auth_lib_value, @@ -1007,7 +1007,7 @@ def test_refresh_impersonation_with_mtls_success( # Only 2 requests should be processed. assert len(request.call_args_list) == 2 # Verify token exchange request parameters. - expected_cert_paths = ("test1", "test2") + expected_cert_paths = ("path/to/cert.pem", "path/to/key.pem") self.assert_token_request_kwargs( request.call_args_list[0][1], token_headers, diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index 1dc52cc3c..20b74b35d 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -1595,7 +1595,7 @@ def test_get_mtls_certs(self, mock_get_workload_cert_and_key_paths): credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() ) - cert, key = credentials._get_mtls_cert() + cert, key = credentials._get_mtls_cert_and_key_location() assert cert == "cert" assert key == "key" @@ -1605,7 +1605,7 @@ def test_get_mtls_certs_invalid(self): ) with pytest.raises(exceptions.RefreshError) as excinfo: - credentials._get_mtls_cert() + credentials._get_mtls_cert_and_key_location() assert excinfo.match( 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' From 457cdf95b60f3b70939a0f9a053572ed788c9d47 Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Wed, 26 Jun 2024 16:16:52 -0700 Subject: [PATCH 5/9] Apply suggestions from code review Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> --- google/auth/external_account.py | 4 ++-- google/auth/identity_pool.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index ef43b5ab8..a986cb7fc 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -396,7 +396,7 @@ def refresh(self, request): scopes = self._scopes if self._scopes is not None else self._default_scopes auth_request = request - # If mtls is required, wrap the incoming request in a partial to set the cert. + # Inject client certificate into request. if self._should_add_mtls(): auth_request = functools.partial( request, cert=self._get_mtls_cert_and_key_location() @@ -554,7 +554,7 @@ def _get_mtls_cert_and_key_location(self): NotImplementedError: When the current credential is not configured for mTLS. """ - raise NotImplementedError("_get_mtls_cert must be implemented.") + raise NotImplementedError("_get_mtls_cert_and_key_location must be implemented.") @classmethod def from_info(cls, info, **kwargs): diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 3330940a6..4174490ee 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -295,7 +295,7 @@ def __init__( self._credential_source_field_name, self._credential_source_headers, ) - else: + else: # self._credential_source_certificate self._subject_token_supplier = _X509Supplier() @_helpers.copy_docstring(external_account.Credentials) From 72ea98d9a1ef37f377b151b86b300464d8460d76 Mon Sep 17 00:00:00 2001 From: aeitzman Date: Thu, 27 Jun 2024 13:27:55 -0700 Subject: [PATCH 6/9] renaming functions, adding comments, and removing auth_request temp var --- google/auth/external_account.py | 15 +++++++-------- google/auth/identity_pool.py | 22 ++++++++++------------ tests/test_external_account.py | 16 ++++++++-------- tests/test_identity_pool.py | 4 ++-- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index a986cb7fc..eb934d849 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -394,19 +394,18 @@ def get_project_id(self, request): @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): scopes = self._scopes if self._scopes is not None else self._default_scopes - auth_request = request # Inject client certificate into request. - if self._should_add_mtls(): - auth_request = functools.partial( - request, cert=self._get_mtls_cert_and_key_location() + if self._mtls_required(): + request = functools.partial( + request, cert=self._get_mtls_cert_and_key_paths() ) if self._should_initialize_impersonated_credentials(): self._impersonated_credentials = self._initialize_impersonated_credentials() if self._impersonated_credentials: - self._impersonated_credentials.refresh(auth_request) + self._impersonated_credentials.refresh(request) self.token = self._impersonated_credentials.token self.expiry = self._impersonated_credentials.expiry else: @@ -422,7 +421,7 @@ def refresh(self, request): ) } response_data = self._sts_client.exchange_token( - request=auth_request, + request=request, grant_type=_STS_GRANT_TYPE, subject_token=self.retrieve_subject_token(request), subject_token_type=self._subject_token_type, @@ -531,7 +530,7 @@ def _create_default_metrics_options(self): return metrics_options - def _should_add_mtls(self): + def _mtls_required(self): """Returns a boolean representing whether the current credential is configured for mTLS and should add a certificate to the outgoing calls to the sts and service account impersonation endpoint. @@ -541,7 +540,7 @@ def _should_add_mtls(self): """ return False - def _get_mtls_cert_and_key_location(self): + def _get_mtls_cert_and_key_paths(self): """Gets the file locations for a certificate and private key file to be used for configuring mTLS for the sts and service account impersonation calls. Currently only expected to return a value when using diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 4174490ee..744bc27d8 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -275,12 +275,12 @@ def __init__( ) # check that only one of file, url, or certificate are provided. - self._validate_only_one_source() + self._validate_single_source() if self._credential_source_certificate: - self._validate_certificate_credential_source() + self._validate_certificate_config() else: - self._validate_file_url_credential_source(credential_source) + self._validate_file_or_url_config(credential_source) if self._credential_source_file: self._subject_token_supplier = _FileSupplier( @@ -304,7 +304,7 @@ def retrieve_subject_token(self, request): self._supplier_context, request ) - def _get_mtls_cert_and_key_location(self): + def _get_mtls_cert_and_key_paths(self): if self._credential_source_certificate is None: raise exceptions.RefreshError( 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' @@ -314,7 +314,7 @@ def _get_mtls_cert_and_key_location(self): self._certificate_config_location ) - def _should_add_mtls(self): + def _mtls_required(self): return self._credential_source_certificate is not None def _create_default_metrics_options(self): @@ -343,25 +343,23 @@ def _constructor_args(self): args.update({"subject_token_supplier": self._subject_token_supplier}) return args - def _validate_certificate_credential_source(self): + def _validate_certificate_config(self): self._certificate_config_location = self._credential_source_certificate.get( "certificate_config_location" ) use_default = self._credential_source_certificate.get( "use_default_certificate_config" ) - if self._certificate_config_location: - if use_default: + if self._certificate_config_location and use_default: raise exceptions.MalformedError( "Invalid certificate configuration, certificate_config_location cannot be specified when use_default_certificate_config = true." ) - else: - if not use_default: + if not self._certificate_config_location and not use_default: raise exceptions.MalformedError( "Invalid certificate configuration, use_default_certificate_config should be true if no certificate_config_location is provided." ) - def _validate_file_url_credential_source(self, credential_source): + def _validate_file_or_url_config(self, credential_source): self._credential_source_headers = credential_source.get("headers") credential_source_format = credential_source.get("format", {}) # Get credential_source format type. When not provided, this @@ -387,7 +385,7 @@ def _validate_file_url_credential_source(self, credential_source): else: self._credential_source_field_name = None - def _validate_only_one_source(self): + def _validate_single_source(self): if ( sum( map( diff --git a/tests/test_external_account.py b/tests/test_external_account.py index d6f656d9a..3c372e629 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -683,16 +683,16 @@ def test_refresh_without_client_auth_success( ) @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) @mock.patch( - "google.auth.external_account.Credentials._should_add_mtls", return_value=True + "google.auth.external_account.Credentials._mtls_required", return_value=True ) @mock.patch( - "google.auth.external_account.Credentials._get_mtls_cert_and_key_location", + "google.auth.external_account.Credentials._get_mtls_cert_and_key_paths", return_value=("path/to/cert.pem", "path/to/key.pem"), ) def test_refresh_with_mtls( self, - mock_get_mtls_cert_and_key_location, - mock_should_add_mtls, + mock_get_mtls_cert_and_key_paths, + mock_mtls_required, unused_utcnow, mock_auth_lib_value, ): @@ -940,16 +940,16 @@ def test_refresh_impersonation_without_client_auth_success( return_value=LANG_LIBRARY_METRICS_HEADER_VALUE, ) @mock.patch( - "google.auth.external_account.Credentials._should_add_mtls", return_value=True + "google.auth.external_account.Credentials._mtls_required", return_value=True ) @mock.patch( - "google.auth.external_account.Credentials._get_mtls_cert_and_key_location", + "google.auth.external_account.Credentials._get_mtls_cert_and_key_paths", return_value=("path/to/cert.pem", "path/to/key.pem"), ) def test_refresh_impersonation_with_mtls_success( self, - mock_get_mtls_cert_and_key_location, - mock_should_add_mtls, + mock_get_mtls_cert_and_key_paths, + mock_mtls_required, mock_metrics_header_value, mock_auth_lib_value, ): diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index 20b74b35d..b2b0063ec 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -1595,7 +1595,7 @@ def test_get_mtls_certs(self, mock_get_workload_cert_and_key_paths): credential_source=self.CREDENTIAL_SOURCE_CERTIFICATE.copy() ) - cert, key = credentials._get_mtls_cert_and_key_location() + cert, key = credentials._get_mtls_cert_and_key_paths() assert cert == "cert" assert key == "key" @@ -1605,7 +1605,7 @@ def test_get_mtls_certs_invalid(self): ) with pytest.raises(exceptions.RefreshError) as excinfo: - credentials._get_mtls_cert_and_key_location() + credentials._get_mtls_cert_and_key_paths() assert excinfo.match( 'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.' From 8d8ad0706ac34ab8317b65a5fee5a111d19d7dd8 Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:24:56 -0700 Subject: [PATCH 7/9] Apply suggestions from code review Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> --- google/auth/identity_pool.py | 23 +++++------------------ google/auth/transport/_mtls_helper.py | 2 +- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 744bc27d8..ad1826255 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -386,27 +386,14 @@ def _validate_file_or_url_config(self, credential_source): self._credential_source_field_name = None def _validate_single_source(self): - if ( - sum( - map( - bool, - [ - self._credential_source_file, - self._credential_source_url, - self._credential_source_certificate, - ], - ) - ) - > 1 - ): + credential_sources = [self._credential_source_file, self._credential_source_url, self._credential_source_certificate] + valid_credential_sources = list(filter(lambda source: source is not None, credential_sources)) + + if len(valid_credential_sources) > 1: raise exceptions.MalformedError( "Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.." ) - if ( - not self._credential_source_file - and not self._credential_source_url - and not self._credential_source_certificate - ): + if len(valid_credential_sources) != 1: raise exceptions.MalformedError( "Missing credential_source. A 'file', 'url', or 'certificate' must be provided." ) diff --git a/google/auth/transport/_mtls_helper.py b/google/auth/transport/_mtls_helper.py index b69ef72ee..6299e2bde 100644 --- a/google/auth/transport/_mtls_helper.py +++ b/google/auth/transport/_mtls_helper.py @@ -115,7 +115,7 @@ def _get_workload_cert_and_key(certificate_config_path=None): def _get_cert_config_path(certificate_config_path=None): - """Gets the certificate configuration full path using the following order of precedence: + """Get the certificate configuration path based on the following order: 1: Explicit override, if set 2: Environment variable, if set From 379c5de056e49311a83a82f0555eef1b1f8d5f57 Mon Sep 17 00:00:00 2001 From: Carl Lundin Date: Tue, 2 Jul 2024 09:57:03 -0700 Subject: [PATCH 8/9] chore: Update test credentials. --- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 254396d3374ff9908e67d7e32443b212482d6bd0..94db780f16017777e75274af736892801f45a760 100644 GIT binary patch literal 10324 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

kOw>TkL@ zd6w!l8dfI<+>HCnf`#P@D<;0(WvpoF@)~giRcv-*qU=V0UEl1R>D!gzwj_5xtix-1 zEQ34j1z$G)ld384K6Sg;rv#pWgK!|ea(!q>KO_!yT-WM1*^{xjCID+{HEH@w;o%sDxBRkT31^^EBf#@raYdGh^Z$7iZ2#Zix>>svbdq>L`Wk*Bl zypJ4&bAxsr>WIUPUkfD;gx~*WX?M_`eAKi?P=xgk{O+_E9ih1N6=qHD?#(xuDxHc` zYvxFrHVeK6^wUZF%ek3JVDFXgOWVxij_hf-3o4~=3c@N$WWY>(3JZV2ca?275ryxt zJ^ICHz9_eVW;@?07V4(Z@_6Sw#{B%JEM*5e3%=twE4`zrC6U4@J@X_z`(=g^`Xct5 z1{ph@&g*w%3a4#Ihpq&T@!#|A-oe3G9y_Mtd?|j_I~}q2`^~OcRrOobLV(w}<#0q9+wnUZ&;O8R(16;# mHER;J*$nz4)^k_vfsVh54N0^(;te%woqwrNW@$8QR1l&kg+E3B literal 10324 zcmV-aD67{BB>?tKRTDDSuHDuHL2?{$wa}}zT)Fd^yRR0>kaFl=TRLO4>h}_=Pylv+ ziVpE={!$xFHHh$M>y5NAlVk=3zn$>bIctd(gv*i75xnRvaB zF_>502@x?3jR4|CAFMm_&l+*x07m#Nz5(e^<{2GPDXEU*Gh++ler6;HNaGcN@VwiM zJ}5;|cliL#Gq3STi^$?VP#CL??GhMj$P^Z^byjSzlZ#0E2CkdhxudhRdx8mX4L&z= z@c5`Md!M(Zi=+9-jo=FZ&5NAz*s88AmtT4sqZyO1=?{NJbuO zT}}`Rx3f!&r-ejF^^q!rYkp+rN!@p5q}|gZsHEMSNkM?R$bl#@V`}B?;(8&TU*|q! zaZ4v-hpDF$VcSZ0^M9?c@vMEz>8`L~4lr6Cthw|^f=i*LoP-_E$DX{`_rA@?0ZV~C zJDT&1v-oX*$^wv-oo7;Kd6Is8?`57yU51xT66#VXWk;&^tIP_%VX6U0IK=3vfqeN` ze7|@i(vcw-t_}Q1=++b03x^ym^vzlQO-$XV*~TZbE}KgWNe+}raJwNvj*(q38(~b> z6}34Vl;=C@#X10#OX5B4yoxCJI*jcJ(Cv3h2@gYRLM(Spo5p<1k(0${z4j{e?=1>EtKer{<|`G~k%W_wi9PgI0GU{tfa6NlZ&tSKjf z;9X*n^6vv21Ko1GR$Lg_B&$Y04~W}#z7={CZk&2GynCn@q-x!Rpg<*?A}+?7clhNI z=~zaXlA$a?FQ<#XJcPsA8tc*{owfD+O3H&|OJ>Uylg>xxY#QA?{xtD>fkOUQO3FPw znlpQN17hZl(|Flsi;b58!L-u)bU#d^k7R_X0VJJeE;`6&v#qUu?lAWUNh$wBF?c$Y z#I3IV={6KP6^_%sJsBt}X=(YG42W8BD`E8Fq8YVq@1KHm;gH}U&-N*9WTC|zO0cL+ ztb$oJ{=MQ6&ZEvMIkbNS?NGypHpm;33Vg&{chzyMlvGe^va6T7Bg#qTE~P7SyIB|vHjtU>L7F14@Ee0K1+o;{cRwUm z?v5G0P9w)GO&7Tp_~iOYLU@&kk}=BENPvf1_xyM?{7uwtSBFH9_U(ac5e5Zwag9P5aoXlyygkGteqy#&u%C)Q7Ny4#{T^1h&*xDZFBz-1g{ zMfEIu2*Ea();006ny2b~4wLF)fam!Y- zd5FtMKFdS&Z)I@MxZWo{_Fo|vXmv6EZ-Z$!CJKE4gj|t_{^(yF)|3*!YgpX9yv^C9 z%Hr_#ySS*S`7||-uDawTVgg$MIsTQ|u%`IBpI24E11{yYOK7YT2*|dC;F>)Er32`M z#EBrbdq9^jf*;w}3ij&>Jd$L4pKi(1)bC~(MD1Vt6h!^GLid(iz6$$y?8#=^$&KF& zhmSJBOCicS4yoP6RLBf#afj1Q)8jg=eU4ZvPta1l^9GRE4NbTvQM3OzB{zXJ1hnvHYtPNht^ z>*|pn;4W{DEhw;^Bh5?Xm(3FuAn2#Rv3Ny z;-9taRYfnHQimV>1U0YdH#rnmPQNv+7YUCAzhy;wt3WB)9jw%Ti$$(c6FXJ}9WV1| z?}T@D0@u{K=&>8(~8a)D-EyX8@ts2DETpItoqaJx`O92vSqP^5Ko64uvbKrOgjkTp{Y*$rxvAqKB9&0G_<~xFw$N;r zcrB)}?o3g4Xb}>UMMNCqU8Ptz^$GeTv-BUlr$4=FdfMa>*(_BGXBS>yQ{vQ1@l3e7 z^=s=C9_cX~^`4mYVURfkc37x_qLyhrDINFgv!;2+mNy~=q?<3RVkPgc>`q{iJ zK;ri4WU^o&Xfr`W3*Urt#_9`iuxVlbk6@^U${6_6NB-L*_HJMxoed_bE z#I4$hmDJ?3zd38s1cuSHa#GiPPO?R4$IY^!)?kWa4Wpt6NB&$tTrqlXYULmPKSdK* zOUL^E(HUP6SqzsKLRBgXJ}&hsrl+XJ4O6#x{#wz6v&(cCT_hcZMsa7---KMP#XW>I zfw`Jonce{c|7&w8E7#=!Y^1MD_RGSwtzyK(}nK#{pM3CxG0;~uN6721!9D()66VZodh+&RUp=rssbd7BL9MN?S_?Yd zaZyCYqL+bZZ3W-o*Gp+{<)dUIQ`35*F~=EPp_EDE<0~WvULCZl&SNDG>ojUdwaIbI zMLs%qM-+rz6RzG`4H1=-tCj51WBdcH*7>)}o#JOVe^w&ikRjqvpBYk+0IUC+&B6^g z=Ci^}|HD#}OMO8#k`GUAjlAG9oRlGoKJArE2g#qS9*?H1v{0t!7r6qII;)dYkU5ne z=lT3SC3j+;GT`D5MD0nUiHQ`9E@`p#v!%#!oDe{=An5KIqKsOE-FrDJiy1jYa&$&F zt8oC?aTPE86WkAHSGHaQ%!*1Ybf<2iwj}cH2ESY-j&379`P~og33&X$sk2pQoy)1N z2;T4x&iKoLKwP}a_Rv^+CS?k_D=N+XrF7yv>WiauRK*9wsF3GD0FCm$*)z$*S+#{u zdL{yq*@asUjB6A;P+7gV{i$O}@!;X@crq}(y+i3-TLq)T=yGm7qnRq-GIJ*C(cJ>E zl5tenOBBOoemN^PNzP84wbcj%pph$q6xZGQBv{}*!y8A({!D1OWM16jh@tL-W{DTH z2ufs3@*lwqjr=e6Tg+}F!`R;Iwc>HWO9#m-A%t~fmQ3v?ZIXyh9st~HsK%MA-__3n zl>3YlvLko<9KL%rk~6qk0KzV){vZeLCYp0#iJN;?4u3x_-(ZNS;WI_`E45@LjarY7 z;KjeNUx&f!j5C|Wp?$J~3SPhN;S@>MAzUoXHIl+pC@}C2q8KZ%dv}9zQPhBn^;-QI z_xKRBZj|kmn`j{9=P~03%C{Sdf7_I>>Fryl7m|RIUjZh%+pcQVwZ&$X>9Uk&5&npY z(Hvas{P~}W_S~3`EBFSc6>=Mz8;G=QcMRLoPMpR{xqKh(&L?Ytl{Zy3Yer6E9dk@c zaf;u{ndl4QYTntt!Ta$EkcSmZzdAl6%`TcC>Q?2=5ki26l?HtmfPFgDaa`wK%;fr! zH>M`1!gs9aI^dhskC}}1bm`cGGZ{ySArQUz1kCoT zusx^NaXWM-7G{(@A)+p8?(J0&wGF8kY zwH{NFMi%RrkQ@JIG3j;Wq=<=gJ?`ggg`d{o!}ig=1H7$q zfB=LS;rf!~oRsVwwn%tis~k^hWN4+dvqeOOT*b5UyZDiXyM+^IUByeL8;#bfWqCJs zv3NLU7LLt4)xF``+&pu(Q6GhRLFAH7z*JXk8r$=RqAmpY4b^GOkE{K0I@z%&y(<`R z_)Nmn(w?Oj&?^}zeF`Tt0 zr0;NIb;fmC0|^_gs{cYsb|E5n&5)V{ zX}3;$^>RYv$?X@p|Fml@6;mA9S6NsIxwgtiLY(D@;4!&{Pdaq*2NLm2c+h8QqB~dN zDSle{D@Qj9Fq?UBI%}b_PW}xOXMa=-N)zT;lqvc`3O>Vt&Z^>b+Gh${?h}xWdRfCP znZuy_MB(hLXyxJkK7&Z@P)(`sp%qcRgBZTd)882*X+KG{{kD)t zvavc_iUX0AaX=Kk1baZgYZvT*Q2@AYk^tz7M6D4KEH3c|S$Pw)%SL#tpht zV6%vy6+4P?^stdi==lb=e`B721(bXX22Loj(36fl02Uotaj#Uv5soXBXF{>nBr~-h zHT#zQEO!nTF|w-AX$Kx!T0qE;|5iCagL4w0GsYn;*)I&8)$HZZ( z0)F9LspN%8i{3IG5Uot%6w6Vqi<$1QlT^-h{|SR3q4jQ6{v=#9MoE-Vn8u&Ep+8>E zPt8JCV<=K{9z>a;XIQ5q1Gu~pd!1{A{XvU5-jD@n(Rfg55&?Revfe+gd1{&nuF$H$M%MP)Y|?8(-m3#Tka#UbdLVGkHX0) zAjg;4*@zg5NLj&f{OSI*k2np_-}oJqouJc$c7%oHI;gT9Kff_+*?iBWls#+!z>~Qe z*d%IO+wWXL3)apeo*{9Qp<$$qR_j;IISF5J3Y<62zPWDqvNDBrnk2=)MC8r#Dj-!p%fL3gZm#~8 zzBBF7(nvhZS~*Z@cj*R$WOhHOSOEF3rPu@U*VD;t?!Q&XAy2siqAcmx=W4uBn0x7h z3nt9?Eux!zHTdaF{pBmjTLg)&^P-YhL^h(`_}o-lgt^(22=8^aJ%L!i(XXE#!So#l zRub>8&L^?~p5^)k784E`f_((rG<}$n5u@G4@s_)hHDNr{BRVV6yH-0kX;@EmX z@%;$yL7DF*@!d0x05qqe*z%ziFnB$n3IA2Hg8G8#`%tD}j91gy_MO?m1)uGy2Wg=N zZt^u?z)EQ~iT;jam`)EWhtDsI1G)1|c*@1w=DWfX0fw>pzrJUuEKs&u)B;R!&WxRD z*~37|w4CFAfe=#v1$beWxj+kPI03NrRewnET{t_W?`%ad z(x^@5Ek!$-Y}zkmdcH?cH;q??>N3ghm)&v`A$hhlW;Et@(q<|dMlo~+mPXLTztYlH zgP5KL)OC^)pT(jU6}n@n7~#$U;l)z5Q2@6&@nECWS&0+6sbKdt0JCR8pJ6}(K4nyM zom4a-T#UZK!;$wL4)^z^%%Qc_avB{kZ#~6eE#m*`2S>)uwLEZI=2D?K-Bx(cj(#VJ zG(B8PP5GQ4C-rD|U53V|bWq_5YDf73pbhPR z6HYXa3QEvYXb&@dP8VvQSboq@4sd>-Ok~^9@Qpot@0{S=ac{-i!ph;FDyo&4T4QR| zua+1n2rqL?z)BWMusqHI?_Td(bi)l1=0znVTnU8T_)YK8eX_ia$sNpo9kQ=o3^5Bt zYV)8+50(*^$hvQns|OM!ogv%#v-1%ie~cyGDKx3O|L+(!@(PUf)^7HQ!yZOU(RbPl zyv6U~o+kejZfalN5sfZRY$o#Na^nDEFV#I)QrL8AZ6WfoKlV7X)&bq$!ho}o+ztcQ zhxgZj*;zgXbS(3eEGFyy<5p*1#PjdRZ`1aT1Z1M;BE|a{wAQN0T^>?x?4qnt1Qak2 zyr9tjZ?%($i!uMG)DzMYVx)qC_ykFNp@Urg+oD_aFV}KcSWGTWa7>2EPUJu z5a*YP80Dv{Bdq%tS(KU0P0P}Pl+W@n3Wd80{Q8rnr*}1aMi!)@3-bm=;b?JcY5<-& zmcf}2TZnVY!AJ?6VFYHufP0Nsp@OP~B>XR)U-vK}lg;VQzu%qa!n*2CE}}Tb|Kjka zr%M&K6YD75P)is(2Y}=!B9W*p-*5|GmuWmEvi4r47N`+bT2=R01Z36w1x$XZf@%Wm zY-)e z>^hINTJD2KGYHIvkx5Y*)V@6yW^%fBDs>fRr_ZsoTVjP~aC8JI*uE_}{^`&N>A zKPHqG&dSpi#<_pQ{=>QomPd)KdZ2q$%OBbgQARa-4KJ@dsO%68Nm1vEb$)ibRU#e7 z`NmW<`bgk#JH;-s6(`btFC@GN?dQn~J*$$cVpLk(hNd*b!aI!=-&jlWeIeH)+s(`_ z8ytJ74X3PC3H!=RY(i=q43OUJ9k%#~O}{00WzncY5?>Sl&L@DF0e#(=E|ytyD`AN^ z(6sVE^r)=N>eS2KLxJsn*rYupo_wX90~SQs$prg#@nf^gw#cY9mn)HD-NTPa!t0PWcGKb@X-8bQZ`;22@Q_L2^l(f6%KqjW$5ieu;>-7!olBhw)V!lsgQmJkS)-$;EHW9ih&?P1~ z(l5ysU$20DOwary4Ds9BiCbyWV6HG^@qXq%N7ey1Uy!((mSRERV-GtL)558O z51%nmiaGY!6XxUO%*x#_VcKKP|CxTH!XKR^;PqHY6K9`?rMJ|;TTUiChK6$MRtRBC z{Y|9%+Wn>3q(X6C;rQ2fgahs;zh*u`4yxOZ%5?_5msHCa5ZsA9+Q1+)HBg>_$`-4LP6)h17y=DEk7(mXiLGrHK73 z;JF?heOF@&O9!ukKYLsMkB2Y4KN+P`i2_|;M`JgxD^4lF3=CPb3aZ=nKv#%4x25k! zeN$Gb2Z!7f)vO;~Ud58{7D}Fc5R%B9bco5$Zo>*Jwkiqe~o(dGOvFc>qn(0RNriu!b8T(&4!zk)cNjh=%t@@g}1MM{vgWs~Pc%#4u?_ zm#-2H<-Qp9!Ye@h)`GWM&GRk%^6Xm2qGacL`Z+F~SOK|~7qqn>2VAVGqGG2#vPQx! z2whziGACbaZ$N(gdx_7mC8?RN)Z})_x3Hat(SKR#K{5^OfbtVx}~&Nyt?z%f z0F`EBN9PXPyTqlM!*k%@O&!7H;-;wvqsrxPT0)>9eXn-Zt|=6{aB~E-!oo~3)b$Xi z20H@k)1vG+*Ah5<9e!poC-2|nQWVf)iLy>1%+v_wc#?1Iy;;&6Rr4G<$gqcWf1a?{ ziR4-f!60Hdv)|PtzJkS<$&ghV|1eMru=86dVEGsd?GF+}IhhwpJ}r0Mdi@~KE>+{> zuhubX!|WsN`O?h+>Qw5`wmI{2E_4&pOOq%D1p>>8#We5(y;G>su}y7f+GPo6aEOd; z^8EUrQ;9|)m#7c0JJv#3pWoDgbYB&>f19-PlkjC8GXiW908igE!}vvU!a&pfKZ8** zAKc1U%I4R`>dtc;tE9FxFCkgVjmC~y>p3DBU;`*%8qdD&LLp^k8HW=>rkQJ{GR>Tq zNs$vL&?AF;x*R{r7uN;0a@(II=E1tySf2yEHFMju{I$uC!kwt2r2lJ+ZU~F5;Nkx_O*uDXU>o@F=dpF&4n6l)0V%dnztR}{|2()Y_wRHJ&WB{o^|)9Sq7 z{e)Mtf2lM%a{Tu{+cD9?g%PE8$L!5*Y@@agH`JThIQ>6q{zUBp*l>L}qef}HpX+n_ ze*WX0bwx5?q*<0-x1rqK?h0Hl4V6U7MmvAMydENgIH$heYj^8jMe)<|Uc=5R`EbjV z+=Ms(SQ1ZZ7@}Rwxj2EEA2`@Vw3&^j@_>Znt*$n3pq)p(B4)q+s?wtA;q(!L@ZY(-3+GxMkOzf?;>a*oFHx_q zeTp$!ANW1PrIx<<4z-oAq`np`yU_32v{6=5-rO2ffu*);rzuB9Yoouh_dKU0vN^5q2A z`b98j`8~`U3KhlFEO=%9UUjw?V_GCs)^Y=Le1#oKzdR|Q#9&lXmvTnSW0qwrlFC)W zf)EAOQ{e8EndQ{@3F{4mv_lUDYg|p@Knv39iaHOg6cTPc)iEX=wkl~IO%RSlaI)=c zhoIY$`apY`(y@-4G1sN{kDsw5evitc$+jVG#jjn7Vxz+ceM2)`7AEujVI) z0+d_B4#k<$WGNv7m82DZMmtfDGV~TR0dWeot zKaxY?Kn^d*QYHJGGOjOqK+-gqK5HCi#y?fe!y*OZx(csqM?ct>n(FSr-DKSVPI7WAKVlIOq17?q5=n)<)SBC5T_vo*` z^j!TqMNu*I!08D-Fw6^)q?k0^DxM{%MNgsx%tiX&Xu%CmkN2nho>?^5t4Q(wPrB`d z`%iZimUzU=v_IeyY+McQub-Ly)G(pbB^#4_3QdQFs4BQdej$C1$$q*F8F8SUTX1Rd z9n|=eT_OATeE9`b`!tSU{#VtVc=k?N=ChPHaaLuq3H`_?NXTHm>AMx7lhMC>Y-NWQ zk&Q+*{s-%I?`lgu9GS_86A*gWpnp76ZJM8<2*JsV565!^O_S<9QbRnDBaS$d3T3lJ z0)6yrig(q~xnv%N(Qi%jugkxFzwPeIo8W~_O6`) zATE2FLJV-Bs?^<1^F49{b5UjC{7YMJe%_9#OPjsNBMyX##4~2<*ZZA_rM7LefpPe5 zWtT8rgqgRjy7cYnhpIFkvS->aqQ~PBk88p4M_$m-eo|(mU4vF|v6)j1z%@@E)V6d^ zu`9%Ff@NTDOKR{HYCoy6gD+BLWHJ&IHvC~~#=C^a7?~30836Q9hs*$>^E83iF1nBB|aU9%+ zZXwb4&?;68L!%Seye7s+CY7|jBmf~5z%WnwGmih zF@87^aW-QS!Qcx+I*VoNuvT0n48BS(?kiy${TnfEFgfE?1B-SJ>1*~Dq(y$ zYe^|2G?m3rPu`(x;?(U&qa3Hh%S}qAfdaV_K8&6WmwT+fM+Zv>W(aHz@3~5~!+vS{ zJF+ESn(aSbbGjXWsiJC>X|evS(8G9wSQc%gJa;l%{e94>HvU|=RK3B3qypp#Bq)bv zU3(-5N)y#S5W$Z}wx!n3ugI~N`KGi1rm*s^BSezhF1>0{*nrVuSpe^{I_Rs%lU5Fj zv(}&G^9&PUR9fyjM8DJ$kF^H$p}Lf&qty&bLGrw9HacZ>e&ysCxT^rmg9uB85CfBEVEXn28c>+{-m4?~38(G>KeiPq5gZad;x0R@^y|v65782mB$= zL7JQMR~>0EmSoK2@#f72t$)@?u?NY?gH8>QI!>T0f?3CcXrPn~$dCWz3=$uZ+-Gdr mc^Vrq8C!)2yZ|o+B(X@9A63$DlgJmFOma>u+c+XS`U~|SsXC$n From 4bfc9bff461961dce6dc23b1bafe6c321e791281 Mon Sep 17 00:00:00 2001 From: aeitzman Date: Tue, 2 Jul 2024 11:11:41 -0700 Subject: [PATCH 9/9] linting --- google/auth/external_account.py | 4 +++- google/auth/identity_pool.py | 28 +++++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index eb934d849..df0511f25 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -553,7 +553,9 @@ def _get_mtls_cert_and_key_paths(self): NotImplementedError: When the current credential is not configured for mTLS. """ - raise NotImplementedError("_get_mtls_cert_and_key_location must be implemented.") + raise NotImplementedError( + "_get_mtls_cert_and_key_location must be implemented." + ) @classmethod def from_info(cls, info, **kwargs): diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index ad1826255..47f9a5571 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -295,7 +295,7 @@ def __init__( self._credential_source_field_name, self._credential_source_headers, ) - else: # self._credential_source_certificate + else: # self._credential_source_certificate self._subject_token_supplier = _X509Supplier() @_helpers.copy_docstring(external_account.Credentials) @@ -351,13 +351,13 @@ def _validate_certificate_config(self): "use_default_certificate_config" ) if self._certificate_config_location and use_default: - raise exceptions.MalformedError( - "Invalid certificate configuration, certificate_config_location cannot be specified when use_default_certificate_config = true." - ) + raise exceptions.MalformedError( + "Invalid certificate configuration, certificate_config_location cannot be specified when use_default_certificate_config = true." + ) if not self._certificate_config_location and not use_default: - raise exceptions.MalformedError( - "Invalid certificate configuration, use_default_certificate_config should be true if no certificate_config_location is provided." - ) + raise exceptions.MalformedError( + "Invalid certificate configuration, use_default_certificate_config should be true if no certificate_config_location is provided." + ) def _validate_file_or_url_config(self, credential_source): self._credential_source_headers = credential_source.get("headers") @@ -386,10 +386,16 @@ def _validate_file_or_url_config(self, credential_source): self._credential_source_field_name = None def _validate_single_source(self): - credential_sources = [self._credential_source_file, self._credential_source_url, self._credential_source_certificate] - valid_credential_sources = list(filter(lambda source: source is not None, credential_sources)) - - if len(valid_credential_sources) > 1: + credential_sources = [ + self._credential_source_file, + self._credential_source_url, + self._credential_source_certificate, + ] + valid_credential_sources = list( + filter(lambda source: source is not None, credential_sources) + ) + + if len(valid_credential_sources) > 1: raise exceptions.MalformedError( "Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.." )