From 6f75dd5de9ee1da4509306ff2e6420b3d88f9d00 Mon Sep 17 00:00:00 2001 From: arithmetic1728 <58957152+arithmetic1728@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:24:40 -0700 Subject: [PATCH 1/5] feat: add cred info to ADC creds (#1587) * feat: add cred info to ADC credentials * address comment * chore: add token info support * chore: update sys test cred --- google/auth/_default.py | 2 + google/auth/compute_engine/credentials.py | 8 ++ google/auth/credentials.py | 11 ++ google/auth/external_account.py | 43 ++++-- .../auth/external_account_authorized_user.py | 34 +++-- google/auth/impersonated_credentials.py | 38 +++-- google/oauth2/credentials.py | 126 +++++++++-------- google/oauth2/service_account.py | 14 +- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes tests/compute_engine/test_credentials.py | 7 + tests/oauth2/test_credentials.py | 130 ++++++++++++++++-- tests/oauth2/test_service_account.py | 17 +++ tests/test__default.py | 32 +++++ tests/test_credentials.py | 5 + tests/test_external_account.py | 65 ++++++--- .../test_external_account_authorized_user.py | 16 +++ tests/test_impersonated_credentials.py | 17 +++ 17 files changed, 442 insertions(+), 123 deletions(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index 63009dfb8..7bbcf8591 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -237,6 +237,7 @@ def _get_gcloud_sdk_credentials(quota_project_id=None): credentials, project_id = load_credentials_from_file( credentials_filename, quota_project_id=quota_project_id ) + credentials._cred_file_path = credentials_filename if not project_id: project_id = _cloud_sdk.get_project_id() @@ -270,6 +271,7 @@ def _get_explicit_environ_credentials(quota_project_id=None): credentials, project_id = load_credentials_from_file( os.environ[environment_vars.CREDENTIALS], quota_project_id=quota_project_id ) + credentials._cred_file_path = f"{explicit_file} file via the GOOGLE_APPLICATION_CREDENTIALS environment variable" return credentials, project_id diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 008b991bb..f0126c0a8 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -157,6 +157,14 @@ def universe_domain(self): self._universe_domain_cached = True return self._universe_domain + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + return { + "credential_source": "metadata server", + "credential_type": "VM credentials", + "principal": self.service_account_email, + } + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): creds = self.__class__( diff --git a/google/auth/credentials.py b/google/auth/credentials.py index e31930311..2c67e0443 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -128,6 +128,17 @@ def universe_domain(self): """The universe domain value.""" return self._universe_domain + def get_cred_info(self): + """The credential information JSON. + + The credential information will be added to auth related error messages + by client library. + + Returns: + Mapping[str, str]: The credential information JSON. + """ + return None + @abc.abstractmethod def refresh(self, request): """Refreshes the access token. diff --git a/google/auth/external_account.py b/google/auth/external_account.py index df0511f25..161e6c50c 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -186,6 +186,7 @@ def __init__( self._supplier_context = SupplierContext( self._subject_token_type, self._audience ) + self._cred_file_path = None if not self.is_workforce_pool and self._workforce_pool_user_project: # Workload identity pools do not support workforce pool user projects. @@ -321,11 +322,24 @@ def token_info_url(self): return self._token_info_url + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + cred_info_json = { + "credential_source": self._cred_file_path, + "credential_type": "external account credentials", + } + if self.service_account_email: + cred_info_json["principal"] = self.service_account_email + return cred_info_json + return None + @_helpers.copy_docstring(credentials.Scoped) def with_scopes(self, scopes, default_scopes=None): kwargs = self._constructor_args() kwargs.update(scopes=scopes, default_scopes=default_scopes) scoped = self.__class__(**kwargs) + scoped._cred_file_path = self._cred_file_path scoped._metrics_options = self._metrics_options return scoped @@ -442,30 +456,31 @@ def refresh(self, request): self.expiry = now + lifetime - @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) - def with_quota_project(self, quota_project_id): - # Return copy of instance with the provided quota project ID. + def _make_copy(self): kwargs = self._constructor_args() - kwargs.update(quota_project_id=quota_project_id) new_cred = self.__class__(**kwargs) + new_cred._cred_file_path = self._cred_file_path new_cred._metrics_options = self._metrics_options return new_cred + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + # Return copy of instance with the provided quota project ID. + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - kwargs = self._constructor_args() - kwargs.update(token_url=token_uri) - new_cred = self.__class__(**kwargs) - new_cred._metrics_options = self._metrics_options - return new_cred + cred = self._make_copy() + cred._token_url = token_uri + return cred @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) def with_universe_domain(self, universe_domain): - kwargs = self._constructor_args() - kwargs.update(universe_domain=universe_domain) - new_cred = self.__class__(**kwargs) - new_cred._metrics_options = self._metrics_options - return new_cred + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred def _should_initialize_impersonated_credentials(self): return ( diff --git a/google/auth/external_account_authorized_user.py b/google/auth/external_account_authorized_user.py index f73387172..4d0c3c680 100644 --- a/google/auth/external_account_authorized_user.py +++ b/google/auth/external_account_authorized_user.py @@ -120,6 +120,7 @@ def __init__( self._quota_project_id = quota_project_id self._scopes = scopes self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN + self._cred_file_path = None if not self.valid and not self.can_refresh: raise exceptions.InvalidOperation( @@ -290,23 +291,38 @@ def refresh(self, request): def _make_sts_request(self, request): return self._sts_client.refresh_token(request, self._refresh_token) + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "external account authorized user credentials", + } + return None + + def _make_copy(self): + kwargs = self.constructor_args() + cred = self.__class__(**kwargs) + cred._cred_file_path = self._cred_file_path + return cred + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): - kwargs = self.constructor_args() - kwargs.update(quota_project_id=quota_project_id) - return self.__class__(**kwargs) + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - kwargs = self.constructor_args() - kwargs.update(token_url=token_uri) - return self.__class__(**kwargs) + cred = self._make_copy() + cred._token_url = token_uri + return cred @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) def with_universe_domain(self, universe_domain): - kwargs = self.constructor_args() - kwargs.update(universe_domain=universe_domain) - return self.__class__(**kwargs) + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred @classmethod def from_info(cls, info, **kwargs): diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 3c6f8712a..c42a93643 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -226,6 +226,7 @@ def __init__( self.expiry = _helpers.utcnow() self._quota_project_id = quota_project_id self._iam_endpoint_override = iam_endpoint_override + self._cred_file_path = None def _metric_header_for_usage(self): return metrics.CRED_TYPE_SA_IMPERSONATE @@ -316,29 +317,40 @@ def signer(self): def requires_scopes(self): return not self._target_scopes - @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) - def with_quota_project(self, quota_project_id): - return self.__class__( + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "impersonated credentials", + "principal": self._target_principal, + } + return None + + def _make_copy(self): + cred = self.__class__( self._source_credentials, target_principal=self._target_principal, target_scopes=self._target_scopes, delegates=self._delegates, lifetime=self._lifetime, - quota_project_id=quota_project_id, + quota_project_id=self._quota_project_id, iam_endpoint_override=self._iam_endpoint_override, ) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.Scoped) def with_scopes(self, scopes, default_scopes=None): - return self.__class__( - self._source_credentials, - target_principal=self._target_principal, - target_scopes=scopes or default_scopes, - delegates=self._delegates, - lifetime=self._lifetime, - quota_project_id=self._quota_project_id, - iam_endpoint_override=self._iam_endpoint_override, - ) + cred = self._make_copy() + cred._target_scopes = scopes or default_scopes + return cred class IDTokenCredentials(credentials.CredentialsWithQuotaProject): diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 5ca00d4c5..a478669cf 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -32,6 +32,7 @@ """ from datetime import datetime +import http.client as http_client import io import json import logging @@ -50,6 +51,9 @@ # The Google OAuth 2.0 token endpoint. Used for authorized user credentials. _GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" +# The Google OAuth 2.0 token info endpoint. Used for getting token info JSON from access tokens. +_GOOGLE_OAUTH2_TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo" + class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject): """Credentials using OAuth 2.0 access and refresh tokens. @@ -151,6 +155,7 @@ def __init__( self._trust_boundary = trust_boundary self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN self._account = account or "" + self._cred_file_path = None def __getstate__(self): """A __getstate__ method must exist for the __setstate__ to be called @@ -189,6 +194,7 @@ def __setstate__(self, d): self._universe_domain = ( d.get("_universe_domain") or credentials.DEFAULT_UNIVERSE_DOMAIN ) + self._cred_file_path = d.get("_cred_file_path") # The refresh_handler setter should be used to repopulate this. self._refresh_handler = None self._refresh_worker = None @@ -278,10 +284,8 @@ def account(self): """str: The user account associated with the credential. If the account is unknown an empty string is returned.""" return self._account - @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) - def with_quota_project(self, quota_project_id): - - return self.__class__( + def _make_copy(self): + cred = self.__class__( self.token, refresh_token=self.refresh_token, id_token=self.id_token, @@ -291,34 +295,39 @@ def with_quota_project(self, quota_project_id): scopes=self.scopes, default_scopes=self.default_scopes, granted_scopes=self.granted_scopes, - quota_project_id=quota_project_id, + quota_project_id=self.quota_project_id, rapt_token=self.rapt_token, enable_reauth_refresh=self._enable_reauth_refresh, trust_boundary=self._trust_boundary, universe_domain=self._universe_domain, account=self._account, ) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + cred_info = { + "credential_source": self._cred_file_path, + "credential_type": "user credentials", + } + if self.account: + cred_info["principal"] = self.account + return cred_info + return None + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - - return self.__class__( - self.token, - refresh_token=self.refresh_token, - id_token=self.id_token, - token_uri=token_uri, - client_id=self.client_id, - client_secret=self.client_secret, - scopes=self.scopes, - default_scopes=self.default_scopes, - granted_scopes=self.granted_scopes, - quota_project_id=self.quota_project_id, - rapt_token=self.rapt_token, - enable_reauth_refresh=self._enable_reauth_refresh, - trust_boundary=self._trust_boundary, - universe_domain=self._universe_domain, - account=self._account, - ) + cred = self._make_copy() + cred._token_uri = token_uri + return cred def with_account(self, account): """Returns a copy of these credentials with a modified account. @@ -329,49 +338,46 @@ def with_account(self, account): Returns: google.oauth2.credentials.Credentials: A new credentials instance. """ - - return self.__class__( - self.token, - refresh_token=self.refresh_token, - id_token=self.id_token, - token_uri=self._token_uri, - client_id=self.client_id, - client_secret=self.client_secret, - scopes=self.scopes, - default_scopes=self.default_scopes, - granted_scopes=self.granted_scopes, - quota_project_id=self.quota_project_id, - rapt_token=self.rapt_token, - enable_reauth_refresh=self._enable_reauth_refresh, - trust_boundary=self._trust_boundary, - universe_domain=self._universe_domain, - account=account, - ) + cred = self._make_copy() + cred._account = account + return cred @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) def with_universe_domain(self, universe_domain): - - return self.__class__( - self.token, - refresh_token=self.refresh_token, - id_token=self.id_token, - token_uri=self._token_uri, - client_id=self.client_id, - client_secret=self.client_secret, - scopes=self.scopes, - default_scopes=self.default_scopes, - granted_scopes=self.granted_scopes, - quota_project_id=self.quota_project_id, - rapt_token=self.rapt_token, - enable_reauth_refresh=self._enable_reauth_refresh, - trust_boundary=self._trust_boundary, - universe_domain=universe_domain, - account=self._account, - ) + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred def _metric_header_for_usage(self): return metrics.CRED_TYPE_USER + def _set_account_from_access_token(self, request): + """Obtain the account from token info endpoint and set the account field. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + """ + # We only set the account if it's not yet set. + if self._account: + return + + if not self.token: + return + + # Make request to token info endpoint with the access token. + # If the token is invalid, it returns 400 error code. + # If the token is valid, it returns 200 status with a JSON. The account + # is the "email" field of the JSON. + token_info_url = "{}?access_token={}".format( + _GOOGLE_OAUTH2_TOKEN_INFO_ENDPOINT, self.token + ) + response = request(method="GET", url=token_info_url) + + if response.status == http_client.OK: + response_data = json.loads(response.data.decode("utf-8")) + self._account = response_data.get("email") + @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): if self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: @@ -408,6 +414,7 @@ def refresh(self, request): ) self.token = token self.expiry = expiry + self._set_account_from_access_token(request) return if ( @@ -444,6 +451,7 @@ def refresh(self, request): self._refresh_token = refresh_token self._id_token = grant_response.get("id_token") self._rapt_token = rapt_token + self._set_account_from_access_token(request) if scopes and "scope" in grant_response: requested_scopes = frozenset(scopes) diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 0e12868f1..98dafa3e3 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -173,6 +173,7 @@ def __init__( """ super(Credentials, self).__init__() + self._cred_file_path = None self._scopes = scopes self._default_scopes = default_scopes self._signer = signer @@ -220,7 +221,7 @@ def _from_signer_and_info(cls, signer, info, **kwargs): "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN ), trust_boundary=info.get("trust_boundary"), - **kwargs + **kwargs, ) @classmethod @@ -294,6 +295,7 @@ def _make_copy(self): always_use_jwt_access=self._always_use_jwt_access, universe_domain=self._universe_domain, ) + cred._cred_file_path = self._cred_file_path return cred @_helpers.copy_docstring(credentials.Scoped) @@ -503,6 +505,16 @@ def signer(self): def signer_email(self): return self._service_account_email + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "service account credentials", + "principal": self.service_account_email, + } + return None + class IDTokenCredentials( credentials.Signing, diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 7a7c5248764a6c1a25b12dcb7cbbadad194b2a6e..c22d40f6adfb4c567bb21756a3be06c4191ec2e1 100644 GIT binary patch literal 10324 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 diff --git a/tests/compute_engine/test_credentials.py b/tests/compute_engine/test_credentials.py index bb29f8c6e..662210fa4 100644 --- a/tests/compute_engine/test_credentials.py +++ b/tests/compute_engine/test_credentials.py @@ -72,6 +72,13 @@ def credentials_fixture(self): universe_domain=FAKE_UNIVERSE_DOMAIN, ) + def test_get_cred_info(self): + assert self.credentials.get_cred_info() == { + "credential_source": "metadata server", + "credential_type": "VM credentials", + "principal": "default", + } + def test_default_state(self): assert not self.credentials.valid # Expiration hasn't been set yet diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index 216641946..c63597f79 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -71,6 +71,76 @@ def test_default_state(self): assert credentials.rapt_token == self.RAPT_TOKEN assert credentials.refresh_handler is None + def test__set_account_from_access_token_no_token(self): + credentials = self.make_credentials() + assert not credentials.token + assert not credentials.account + + credentials._set_account_from_access_token(mock.Mock()) + assert not credentials.account + + def test__set_account_from_access_token_account_already_set(self): + credentials = self.make_credentials() + credentials.token = "fake-token" + credentials._account = "fake-account" + + credentials._set_account_from_access_token(mock.Mock()) + assert credentials.account == "fake-account" + + def test__set_account_from_access_token_error_response(self): + credentials = self.make_credentials() + credentials.token = "fake-token" + assert not credentials.account + + mock_response = mock.Mock() + mock_response.status = 400 + mock_request = mock.Mock(return_value=mock_response) + credentials._set_account_from_access_token(mock_request) + assert not credentials.account + + def test__set_account_from_access_token_success(self): + credentials = self.make_credentials() + credentials.token = "fake-token" + assert not credentials.account + + mock_response = mock.Mock() + mock_response.status = 200 + mock_response.data = ( + b'{"aud": "aud", "sub": "sub", "scope": "scope", "email": "fake-account"}' + ) + + mock_request = mock.Mock(return_value=mock_response) + credentials._set_account_from_access_token(mock_request) + assert credentials.account == "fake-account" + + def test_get_cred_info(self): + credentials = self.make_credentials() + credentials._account = "fake-account" + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "user credentials", + "principal": "fake-account", + } + + def test_get_cred_info_no_account(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "user credentials", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_token_usage_metrics(self): credentials = self.make_credentials() credentials.token = "token" @@ -135,12 +205,15 @@ def test_refresh_with_non_default_universe_domain(self): "refresh is only supported in the default googleapis.com universe domain" ) + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) - def test_refresh_success(self, unused_utcnow, refresh_grant): + def test_refresh_success(self, unused_utcnow, refresh_grant, set_account): token = "token" new_rapt_token = "new_rapt_token" expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) @@ -186,6 +259,8 @@ def test_refresh_success(self, unused_utcnow, refresh_grant): # expired) assert credentials.valid + set_account.assert_called_once() + def test_refresh_no_refresh_token(self): request = mock.create_autospec(transport.Request) credentials_ = credentials.Credentials(token=None, refresh_token=None) @@ -195,13 +270,16 @@ def test_refresh_no_refresh_token(self): request.assert_not_called() + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) def test_refresh_with_refresh_token_and_refresh_handler( - self, unused_utcnow, refresh_grant + self, unused_utcnow, refresh_grant, set_account ): token = "token" new_rapt_token = "new_rapt_token" @@ -261,8 +339,15 @@ def test_refresh_with_refresh_token_and_refresh_handler( # higher priority. refresh_handler.assert_not_called() + set_account.assert_called_once() + + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) - def test_refresh_with_refresh_handler_success_scopes(self, unused_utcnow): + def test_refresh_with_refresh_handler_success_scopes( + self, unused_utcnow, set_account + ): expected_expiry = datetime.datetime.min + datetime.timedelta(seconds=2800) refresh_handler = mock.Mock(return_value=("ACCESS_TOKEN", expected_expiry)) scopes = ["email", "profile"] @@ -286,11 +371,17 @@ def test_refresh_with_refresh_handler_success_scopes(self, unused_utcnow): assert creds.expiry == expected_expiry assert creds.valid assert not creds.expired + set_account.assert_called_once() # Confirm refresh handler called with the expected arguments. refresh_handler.assert_called_with(request, scopes=scopes) + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) - def test_refresh_with_refresh_handler_success_default_scopes(self, unused_utcnow): + def test_refresh_with_refresh_handler_success_default_scopes( + self, unused_utcnow, set_account + ): expected_expiry = datetime.datetime.min + datetime.timedelta(seconds=2800) original_refresh_handler = mock.Mock( return_value=("UNUSED_TOKEN", expected_expiry) @@ -318,6 +409,7 @@ def test_refresh_with_refresh_handler_success_default_scopes(self, unused_utcnow assert creds.expiry == expected_expiry assert creds.valid assert not creds.expired + set_account.assert_called_once() # default_scopes should be used since no developer provided scopes # are provided. refresh_handler.assert_called_with(request, scopes=default_scopes) @@ -411,13 +503,16 @@ def test_refresh_with_refresh_handler_expired_token(self, unused_utcnow): # Confirm refresh handler called with the expected arguments. refresh_handler.assert_called_with(request, scopes=scopes) + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) def test_credentials_with_scopes_requested_refresh_success( - self, unused_utcnow, refresh_grant + self, unused_utcnow, refresh_grant, set_account ): scopes = ["email", "profile"] default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] @@ -473,18 +568,22 @@ def test_credentials_with_scopes_requested_refresh_success( assert creds.has_scopes(scopes) assert creds.rapt_token == new_rapt_token assert creds.granted_scopes == scopes + set_account.assert_called_once() # Check that the credentials are valid (have a token and are not # expired.) assert creds.valid + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) def test_credentials_with_only_default_scopes_requested( - self, unused_utcnow, refresh_grant + self, unused_utcnow, refresh_grant, set_account ): default_scopes = ["email", "profile"] token = "token" @@ -538,18 +637,22 @@ def test_credentials_with_only_default_scopes_requested( assert creds.has_scopes(default_scopes) assert creds.rapt_token == new_rapt_token assert creds.granted_scopes == default_scopes + set_account.assert_called_once() # Check that the credentials are valid (have a token and are not # expired.) assert creds.valid + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) def test_credentials_with_scopes_returned_refresh_success( - self, unused_utcnow, refresh_grant + self, unused_utcnow, refresh_grant, set_account ): scopes = ["email", "profile"] token = "token" @@ -603,18 +706,22 @@ def test_credentials_with_scopes_returned_refresh_success( assert creds.has_scopes(scopes) assert creds.rapt_token == new_rapt_token assert creds.granted_scopes == scopes + set_account.assert_called_once() # Check that the credentials are valid (have a token and are not # expired.) assert creds.valid + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) def test_credentials_with_only_default_scopes_requested_different_granted_scopes( - self, unused_utcnow, refresh_grant + self, unused_utcnow, refresh_grant, set_account ): default_scopes = ["email", "profile"] token = "token" @@ -668,18 +775,22 @@ def test_credentials_with_only_default_scopes_requested_different_granted_scopes assert creds.has_scopes(default_scopes) assert creds.rapt_token == new_rapt_token assert creds.granted_scopes == ["email"] + set_account.assert_called_once() # Check that the credentials are valid (have a token and are not # expired.) assert creds.valid + @mock.patch.object( + credentials.Credentials, "_set_account_from_access_token", autospec=True + ) @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) def test_credentials_with_scopes_refresh_different_granted_scopes( - self, unused_utcnow, refresh_grant + self, unused_utcnow, refresh_grant, set_account ): scopes = ["email", "profile"] scopes_returned = ["email"] @@ -737,6 +848,7 @@ def test_credentials_with_scopes_refresh_different_granted_scopes( assert creds.has_scopes(scopes) assert creds.rapt_token == new_rapt_token assert creds.granted_scopes == scopes_returned + set_account.assert_called_once() # Check that the credentials are valid (have a token and are not # expired.) diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index f16a43fb9..2c3fea5b2 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -68,6 +68,23 @@ def make_credentials(cls, universe_domain=DEFAULT_UNIVERSE_DOMAIN): universe_domain=universe_domain, ) + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "service account credentials", + "principal": "service-account@example.com", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_constructor_no_universe_domain(self): credentials = service_account.Credentials( SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI, universe_domain=None diff --git a/tests/test__default.py b/tests/test__default.py index cb9a7c130..d17c747af 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -882,6 +882,38 @@ def test_default_early_out(unused_get): assert _default.default() == (MOCK_CREDENTIALS, mock.sentinel.project_id) +@mock.patch( + "google.auth._default.load_credentials_from_file", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_cred_file_path_env_var(unused_load_cred, monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, "/path/to/file") + cred, _ = _default.default() + assert ( + cred._cred_file_path + == "/path/to/file file via the GOOGLE_APPLICATION_CREDENTIALS environment variable" + ) + + +@mock.patch("os.path.isfile", return_value=True, autospec=True) +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", + return_value="/path/to/adc/file", + autospec=True, +) +@mock.patch( + "google.auth._default.load_credentials_from_file", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_cred_file_path_gcloud( + unused_load_cred, unused_get_adc_file, unused_isfile +): + cred, _ = _default.default() + assert cred._cred_file_path == "/path/to/adc/file" + + @mock.patch( "google.auth._default._get_explicit_environ_credentials", return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 8e6bbc963..e11bcb4e5 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -52,6 +52,11 @@ def test_credentials_constructor(): assert not credentials._use_non_blocking_refresh +def test_credentials_get_cred_info(): + credentials = CredentialsImpl() + assert not credentials.get_cred_info() + + def test_with_non_blocking_refresh(): c = CredentialsImpl() c.with_non_blocking_refresh() diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 3c372e629..bddcb4afa 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -275,6 +275,31 @@ def assert_resource_manager_request_kwargs( assert request_kwargs["headers"] == headers assert "body" not in request_kwargs + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account credentials", + } + + credentials._service_account_impersonation_url = ( + self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account credentials", + "principal": SERVICE_ACCOUNT_EMAIL, + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_default_state(self): credentials = self.make_credentials( service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL @@ -469,25 +494,29 @@ def test_with_quota_project_full_options_propagated(self): with mock.patch.object( external_account.Credentials, "__init__", return_value=None ) as mock_init: - credentials.with_quota_project("project-foo") + new_cred = credentials.with_quota_project("project-foo") - # Confirm with_quota_project initialized the credential with the - # expected parameters and quota project ID. - mock_init.assert_called_once_with( - audience=self.AUDIENCE, - subject_token_type=self.SUBJECT_TOKEN_TYPE, - token_url=self.TOKEN_URL, - token_info_url=self.TOKEN_INFO_URL, - credential_source=self.CREDENTIAL_SOURCE, - service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, - service_account_impersonation_options={"token_lifetime_seconds": 2800}, - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - quota_project_id="project-foo", - scopes=self.SCOPES, - default_scopes=["default1"], - universe_domain=DEFAULT_UNIVERSE_DOMAIN, - ) + # Confirm with_quota_project initialized the credential with the + # expected parameters. + mock_init.assert_called_once_with( + audience=self.AUDIENCE, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + token_info_url=self.TOKEN_INFO_URL, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + quota_project_id=self.QUOTA_PROJECT_ID, + scopes=self.SCOPES, + default_scopes=["default1"], + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + ) + + # Confirm with_quota_project sets the correct quota project after + # initialization. + assert new_cred.quota_project_id == "project-foo" def test_info(self): credentials = self.make_credentials(universe_domain="dummy_universe.com") diff --git a/tests/test_external_account_authorized_user.py b/tests/test_external_account_authorized_user.py index 743ee9c84..93926a131 100644 --- a/tests/test_external_account_authorized_user.py +++ b/tests/test_external_account_authorized_user.py @@ -83,6 +83,22 @@ def make_mock_request(cls, status=http_client.OK, data=None): return request + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account authorized user credentials", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_default_state(self): creds = self.make_credentials() diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index a2bf31bf8..83e260638 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -135,6 +135,23 @@ def make_credentials( iam_endpoint_override=iam_endpoint_override, ) + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "impersonated credentials", + "principal": "impersonated@project.iam.gserviceaccount.com", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_make_from_user_credentials(self): credentials = self.make_credentials( source_credentials=self.USER_SOURCE_CREDENTIALS From 2910b6b56f8b82ad6b2e78befb7d0b3fbe96042d Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 16 Sep 2024 17:44:27 -0400 Subject: [PATCH 2/5] feat: Add support for asynchronous `AuthorizedSession` api (#1577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initial setup for async auth sessions api (#1571) * chore: initial setup for async auth sessions api * fix whitespace * add init file * update file names to aiohttp * update import statement * feat: Implement asynchronous timeout context manager (#1569) * feat: implement async timeout guard * add docstring * clean whitespace * update import file name * add missing return statement * update test cases * update test cases * include underlying timeout exception in trace * avoid the cost of actual time * feat: Implement asynchronous `AuthorizedSession` api response class (#1575) * feat: implement asynchronous response class for AuthorizedSessions API * check if aiohttp is installed and avoid tests dependency * update content to be async * update docstring to be specific to aiohttp * add type checking and avoid leaking underlying API responses * add test case for iterating chunks * add read method to response interface * address PR comments * fix lint issues * feat: Implement asynchronous `AuthorizedSession` api request class (#1579) * feat: implement request class for asynchoronous AuthorizedSession API * add type checking and address TODOs * remove default values from interface methods * aiohttp reponse close method must not be awaited * cleanup * update Request class docstring * feat: Implement asynchronous `AuthorizedSession` class (#1580) * feat: Implement Asynchronous AuthorizedSession class * add comment for implementing locks within refresh * move timeout guard to sessions * add unit tests and code cleanup * implement async exponential backoff iterator * cleanup * add testing for http methods and cleanup * update number of retries to 3 * refactor test cases * fix linter and mypy issues * fix pytest code coverage * fix: avoid leaking api error for closed session * add error handling for response * cleanup default values and add test coverage * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * cleanup: minor code cleanup (#1589) * chore: Add aiohttp requirements test constraint. (#1566) See https://github.com/googleapis/google-auth-library-python/issues/1565 for more information. * chore(main): release 2.33.0 (#1560) * chore(main): release 2.33.0 * fix: retry token request on retryable status code (#1563) * fix: retry token request on retryable status code * feat(auth): Update get_client_ssl_credentials to support X.509 workload certs (#1558) * feat(auth): Update get_client_ssl_credentials to support X.509 workload certs * feat(auth): Update has_default_client_cert_source * feat(auth): Fix formatting * feat(auth): Fix test__mtls_helper.py * feat(auth): Fix function name in tests * chore: Refresh system test creds. * feat(auth): Fix style * feat(auth): Fix casing * feat(auth): Fix linter issue * feat(auth): Fix coverage issue --------- Co-authored-by: Carl Lundin Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> * chore: Update ECP deps. (#1583) * chore(main): release 2.34.0 (#1574) * cleanup: minor code cleanup * fix lint issues --------- Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Andy Zhao Co-authored-by: Carl Lundin * update secrets from forked repo --------- Co-authored-by: Owl Bot Co-authored-by: arithmetic1728 <58957152+arithmetic1728@users.noreply.github.com> Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Andy Zhao Co-authored-by: Carl Lundin --- google/auth/_exponential_backoff.py | 77 +++++-- google/auth/aio/transport/__init__.py | 144 ++++++++++++ google/auth/aio/transport/aiohttp.py | 184 +++++++++++++++ google/auth/aio/transport/sessions.py | 268 ++++++++++++++++++++++ google/auth/exceptions.py | 8 + system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes tests/test__exponential_backoff.py | 41 ++++ tests/transport/aio/test_aiohttp.py | 170 ++++++++++++++ tests/transport/aio/test_sessions.py | 311 ++++++++++++++++++++++++++ 9 files changed, 1187 insertions(+), 16 deletions(-) create mode 100644 google/auth/aio/transport/__init__.py create mode 100644 google/auth/aio/transport/aiohttp.py create mode 100644 google/auth/aio/transport/sessions.py create mode 100644 tests/transport/aio/test_aiohttp.py create mode 100644 tests/transport/aio/test_sessions.py diff --git a/google/auth/_exponential_backoff.py b/google/auth/_exponential_backoff.py index 04f9f9764..89853448f 100644 --- a/google/auth/_exponential_backoff.py +++ b/google/auth/_exponential_backoff.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import random import time @@ -38,9 +39,8 @@ """ -class ExponentialBackoff: - """An exponential backoff iterator. This can be used in a for loop to - perform requests with exponential backoff. +class _BaseExponentialBackoff: + """An exponential backoff iterator base class. Args: total_attempts Optional[int]: @@ -84,9 +84,40 @@ def __init__( self._multiplier = multiplier self._backoff_count = 0 - def __iter__(self): + @property + def total_attempts(self): + """The total amount of backoff attempts that will be made.""" + return self._total_attempts + + @property + def backoff_count(self): + """The current amount of backoff attempts that have been made.""" + return self._backoff_count + + def _reset(self): self._backoff_count = 0 self._current_wait_in_seconds = self._initial_wait_seconds + + def _calculate_jitter(self): + jitter_variance = self._current_wait_in_seconds * self._randomization_factor + jitter = random.uniform( + self._current_wait_in_seconds - jitter_variance, + self._current_wait_in_seconds + jitter_variance, + ) + + return jitter + + +class ExponentialBackoff(_BaseExponentialBackoff): + """An exponential backoff iterator. This can be used in a for loop to + perform requests with exponential backoff. + """ + + def __init__(self, *args, **kwargs): + super(ExponentialBackoff, self).__init__(*args, **kwargs) + + def __iter__(self): + self._reset() return self def __next__(self): @@ -97,23 +128,37 @@ def __next__(self): if self._backoff_count <= 1: return self._backoff_count - jitter_variance = self._current_wait_in_seconds * self._randomization_factor - jitter = random.uniform( - self._current_wait_in_seconds - jitter_variance, - self._current_wait_in_seconds + jitter_variance, - ) + jitter = self._calculate_jitter() time.sleep(jitter) self._current_wait_in_seconds *= self._multiplier return self._backoff_count - @property - def total_attempts(self): - """The total amount of backoff attempts that will be made.""" - return self._total_attempts - @property - def backoff_count(self): - """The current amount of backoff attempts that have been made.""" +class AsyncExponentialBackoff(_BaseExponentialBackoff): + """An async exponential backoff iterator. This can be used in a for loop to + perform async requests with exponential backoff. + """ + + def __init__(self, *args, **kwargs): + super(AsyncExponentialBackoff, self).__init__(*args, **kwargs) + + def __aiter__(self): + self._reset() + return self + + async def __anext__(self): + if self._backoff_count >= self._total_attempts: + raise StopAsyncIteration + self._backoff_count += 1 + + if self._backoff_count <= 1: + return self._backoff_count + + jitter = self._calculate_jitter() + + await asyncio.sleep(jitter) + + self._current_wait_in_seconds *= self._multiplier return self._backoff_count diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py new file mode 100644 index 000000000..166a3be50 --- /dev/null +++ b/google/auth/aio/transport/__init__.py @@ -0,0 +1,144 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Transport - Asynchronous HTTP client library support. + +:mod:`google.auth.aio` is designed to work with various asynchronous client libraries such +as aiohttp. In order to work across these libraries with different +interfaces some abstraction is needed. + +This module provides two interfaces that are implemented by transport adapters +to support HTTP libraries. :class:`Request` defines the interface expected by +:mod:`google.auth` to make asynchronous requests. :class:`Response` defines the interface +for the return value of :class:`Request`. +""" + +import abc +from typing import AsyncGenerator, Mapping, Optional + +import google.auth.transport + + +_DEFAULT_TIMEOUT_SECONDS = 180 + +DEFAULT_RETRYABLE_STATUS_CODES = google.auth.transport.DEFAULT_RETRYABLE_STATUS_CODES +"""Sequence[int]: HTTP status codes indicating a request can be retried. +""" + + +DEFAULT_MAX_RETRY_ATTEMPTS = 3 +"""int: How many times to retry a request.""" + + +class Response(metaclass=abc.ABCMeta): + """Asynchronous HTTP Response Interface.""" + + @property + @abc.abstractmethod + def status_code(self) -> int: + """ + The HTTP response status code. + + Returns: + int: The HTTP response status code. + + """ + raise NotImplementedError("status_code must be implemented.") + + @property + @abc.abstractmethod + def headers(self) -> Mapping[str, str]: + """The HTTP response headers. + + Returns: + Mapping[str, str]: The HTTP response headers. + """ + raise NotImplementedError("headers must be implemented.") + + @abc.abstractmethod + async def content(self, chunk_size: int) -> AsyncGenerator[bytes, None]: + """The raw response content. + + Args: + chunk_size (int): The size of each chunk. + + Yields: + AsyncGenerator[bytes, None]: An asynchronous generator yielding + response chunks as bytes. + """ + raise NotImplementedError("content must be implemented.") + + @abc.abstractmethod + async def read(self) -> bytes: + """Read the entire response content as bytes. + + Returns: + bytes: The entire response content. + """ + raise NotImplementedError("read must be implemented.") + + @abc.abstractmethod + async def close(self): + """Close the response after it is fully consumed to resource.""" + raise NotImplementedError("close must be implemented.") + + +class Request(metaclass=abc.ABCMeta): + """Interface for a callable that makes HTTP requests. + + Specific transport implementations should provide an implementation of + this that adapts their specific request / response API. + + .. automethod:: __call__ + """ + + @abc.abstractmethod + async def __call__( + self, + url: str, + method: str, + body: Optional[bytes], + headers: Optional[Mapping[str, str]], + timeout: float, + **kwargs + ) -> Response: + """Make an HTTP request. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (Optional[bytes]): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (float): The number of seconds to wait for a + response from the server. If not specified or if None, the + transport-specific default timeout will be used. + kwargs: Additional arguments passed on to the transport's + request method. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # pylint: disable=redundant-returns-doc, missing-raises-doc + # (pylint doesn't play well with abstract docstrings.) + raise NotImplementedError("__call__ must be implemented.") + + async def close(self) -> None: + """ + Close the underlying session. + """ + raise NotImplementedError("close must be implemented.") diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py new file mode 100644 index 000000000..074d1491c --- /dev/null +++ b/google/auth/aio/transport/aiohttp.py @@ -0,0 +1,184 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Transport adapter for Asynchronous HTTP Requests based on aiohttp. +""" + +import asyncio +from typing import AsyncGenerator, Mapping, Optional + +try: + import aiohttp # type: ignore +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." + ) from caught_exc + +from google.auth import _helpers +from google.auth import exceptions +from google.auth.aio import transport + + +class Response(transport.Response): + """ + Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AsyncAuthorizedSession``. + + Args: + response (aiohttp.ClientResponse): An instance of aiohttp.ClientResponse. + + Attributes: + status_code (int): The HTTP status code of the response. + headers (Mapping[str, str]): The HTTP headers of the response. + """ + + def __init__(self, response: aiohttp.ClientResponse): + self._response = response + + @property + @_helpers.copy_docstring(transport.Response) + def status_code(self) -> int: + return self._response.status + + @property + @_helpers.copy_docstring(transport.Response) + def headers(self) -> Mapping[str, str]: + return {key: value for key, value in self._response.headers.items()} + + @_helpers.copy_docstring(transport.Response) + async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: + try: + async for chunk in self._response.content.iter_chunked( + chunk_size + ): # pragma: no branch + yield chunk + except aiohttp.ClientPayloadError as exc: + raise exceptions.ResponseError( + "Failed to read from the payload stream." + ) from exc + + @_helpers.copy_docstring(transport.Response) + async def read(self) -> bytes: + try: + return await self._response.read() + except aiohttp.ClientResponseError as exc: + raise exceptions.ResponseError("Failed to read the response body.") from exc + + @_helpers.copy_docstring(transport.Response) + async def close(self): + self._response.close() + + +class Request(transport.Request): + """Asynchronous Requests request adapter. + + This class is used internally for making requests using aiohttp + in a consistent way. If you use :class:`google.auth.aio.transport.sessions.AsyncAuthorizedSession` + you do not need to construct or use this class directly. + + This class can be useful if you want to configure a Request callable + with a custom ``aiohttp.ClientSession`` in :class:`AuthorizedSession` or if + you want to manually refresh a :class:`~google.auth.aio.credentials.Credentials` instance:: + + import aiohttp + import google.auth.aio.transport.aiohttp + + # Default example: + request = google.auth.aio.transport.aiohttp.Request() + await credentials.refresh(request) + + # Custom aiohttp Session Example: + session = session=aiohttp.ClientSession(auto_decompress=False) + request = google.auth.aio.transport.aiohttp.Request(session=session) + auth_sesion = google.auth.aio.transport.sessions.AsyncAuthorizedSession(auth_request=request) + + Args: + session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used + to make HTTP requests. If not specified, a session will be created. + + .. automethod:: __call__ + """ + + def __init__(self, session: aiohttp.ClientSession = None): + self._session = session + self._closed = False + + async def __call__( + self, + url: str, + method: str = "GET", + body: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + """ + Make an HTTP request using aiohttp. + + Args: + url (str): The URL to be requested. + method (Optional[str]): + The HTTP method to use for the request. Defaults to 'GET'. + body (Optional[bytes]): + The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): + Request headers. + timeout (float): The number of seconds to wait for a + response from the server. If not specified or if None, the + requests default timeout will be used. + kwargs: Additional arguments passed through to the underlying + aiohttp :meth:`aiohttp.Session.request` method. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + - google.auth.exceptions.TransportError: If the request fails or if the session is closed. + - google.auth.exceptions.TimeoutError: If the request times out. + """ + + try: + if self._closed: + raise exceptions.TransportError("session is closed.") + + if not self._session: + self._session = aiohttp.ClientSession() + + client_timeout = aiohttp.ClientTimeout(total=timeout) + response = await self._session.request( + method, + url, + data=body, + headers=headers, + timeout=client_timeout, + **kwargs, + ) + return Response(response) + + except aiohttp.ClientError as caught_exc: + client_exc = exceptions.TransportError(f"Failed to send request to {url}.") + raise client_exc from caught_exc + + except asyncio.TimeoutError as caught_exc: + timeout_exc = exceptions.TimeoutError( + f"Request timed out after {timeout} seconds." + ) + raise timeout_exc from caught_exc + + async def close(self) -> None: + """ + Close the underlying aiohttp session to release the acquired resources. + """ + if not self._closed and self._session: + await self._session.close() + self._closed = True diff --git a/google/auth/aio/transport/sessions.py b/google/auth/aio/transport/sessions.py new file mode 100644 index 000000000..fea7cbbb2 --- /dev/null +++ b/google/auth/aio/transport/sessions.py @@ -0,0 +1,268 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from contextlib import asynccontextmanager +import functools +import time +from typing import Mapping, Optional + +from google.auth import _exponential_backoff, exceptions +from google.auth.aio import transport +from google.auth.aio.credentials import Credentials +from google.auth.exceptions import TimeoutError + +try: + from google.auth.aio.transport.aiohttp import Request as AiohttpRequest + + AIOHTTP_INSTALLED = True +except ImportError: # pragma: NO COVER + AIOHTTP_INSTALLED = False + + +@asynccontextmanager +async def timeout_guard(timeout): + """ + timeout_guard is an asynchronous context manager to apply a timeout to an asynchronous block of code. + + Args: + timeout (float): The time in seconds before the context manager times out. + + Raises: + google.auth.exceptions.TimeoutError: If the code within the context exceeds the provided timeout. + + Usage: + async with timeout_guard(10) as with_timeout: + await with_timeout(async_function()) + """ + start = time.monotonic() + total_timeout = timeout + + def _remaining_time(): + elapsed = time.monotonic() - start + remaining = total_timeout - elapsed + if remaining <= 0: + raise TimeoutError( + f"Context manager exceeded the configured timeout of {total_timeout}s." + ) + return remaining + + async def with_timeout(coro): + try: + remaining = _remaining_time() + response = await asyncio.wait_for(coro, remaining) + return response + except (asyncio.TimeoutError, TimeoutError) as e: + raise TimeoutError( + f"The operation {coro} exceeded the configured timeout of {total_timeout}s." + ) from e + + try: + yield with_timeout + + finally: + _remaining_time() + + +class AsyncAuthorizedSession: + """This is an asynchronous implementation of :class:`google.auth.requests.AuthorizedSession` class. + We utilize an instance of a class that implements :class:`google.auth.aio.transport.Request` configured + by the caller or otherwise default to `google.auth.aio.transport.aiohttp.Request` if the external aiohttp + package is installed. + + A Requests Session class with credentials. + + This class is used to perform asynchronous requests to API endpoints that require + authorization:: + + import aiohttp + from google.auth.aio.transport import sessions + + async with sessions.AsyncAuthorizedSession(credentials) as authed_session: + response = await authed_session.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + The underlying :meth:`request` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + Args: + credentials (google.auth.aio.credentials.Credentials): + The credentials to add to the request. + auth_request (Optional[google.auth.aio.transport.Request]): + An instance of a class that implements + :class:`~google.auth.aio.transport.Request` used to make requests + and refresh credentials. If not passed, + an instance of :class:`~google.auth.aio.transport.aiohttp.Request` + is created. + + Raises: + - google.auth.exceptions.TransportError: If `auth_request` is `None` + and the external package `aiohttp` is not installed. + - google.auth.exceptions.InvalidType: If the provided credentials are + not of type `google.auth.aio.credentials.Credentials`. + """ + + def __init__( + self, credentials: Credentials, auth_request: Optional[transport.Request] = None + ): + if not isinstance(credentials, Credentials): + raise exceptions.InvalidType( + f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`" + ) + self._credentials = credentials + _auth_request = auth_request + if not _auth_request and AIOHTTP_INSTALLED: + _auth_request = AiohttpRequest() + if _auth_request is None: + raise exceptions.TransportError( + "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." + ) + self._auth_request = _auth_request + + async def request( + self, + method: str, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + """ + Args: + method (str): The http method used to make the request. + url (str): The URI to be requested. + data (Optional[bytes]): The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): Request headers. + timeout (float): + The amount of time in seconds to wait for the server response + with each individual request. + max_allowed_time (float): + If the method runs longer than this, a ``Timeout`` exception is + automatically raised. Unlike the ``timeout`` parameter, this + value applies to the total method execution time, even if + multiple requests are made under the hood. + + Mind that it is not guaranteed that the timeout error is raised + at ``max_allowed_time``. It might take longer, for example, if + an underlying request takes a lot of time, but the request + itself does not timeout, e.g. if a large file is being + transmitted. The timout error will be raised after such + request completes. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TimeoutError: If the method does not complete within + the configured `max_allowed_time` or the request exceeds the configured + `timeout`. + """ + + retries = _exponential_backoff.AsyncExponentialBackoff( + total_attempts=transport.DEFAULT_MAX_RETRY_ATTEMPTS + ) + async with timeout_guard(max_allowed_time) as with_timeout: + await with_timeout( + # Note: before_request will attempt to refresh credentials if expired. + self._credentials.before_request( + self._auth_request, method, url, headers + ) + ) + # Workaround issue in python 3.9 related to code coverage by adding `# pragma: no branch` + # See https://github.com/googleapis/gapic-generator-python/pull/1174#issuecomment-1025132372 + async for _ in retries: # pragma: no branch + response = await with_timeout( + self._auth_request(url, method, data, headers, timeout, **kwargs) + ) + if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: + break + return response + + @functools.wraps(request) + async def get( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "GET", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def post( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "POST", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def put( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "PUT", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def patch( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "PATCH", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def delete( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "DELETE", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + async def close(self) -> None: + """ + Close the underlying auth request session. + """ + await self._auth_request.close() diff --git a/google/auth/exceptions.py b/google/auth/exceptions.py index fcbe61b74..feb9f7411 100644 --- a/google/auth/exceptions.py +++ b/google/auth/exceptions.py @@ -98,3 +98,11 @@ class InvalidType(DefaultCredentialsError, TypeError): class OSError(DefaultCredentialsError, EnvironmentError): """Used to wrap EnvironmentError(OSError after python3.3).""" + + +class TimeoutError(GoogleAuthError): + """Used to indicate a timeout error occurred during an HTTP request.""" + + +class ResponseError(GoogleAuthError): + """Used to indicate an error occurred when reading an HTTP response.""" diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index c22d40f6adfb4c567bb21756a3be06c4191ec2e1..7f0fd8d86ea09a1a7c44877f3f2bf39ff3509c39 100644 GIT binary patch literal 10324 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 bytes: + content = await self.content(1024) + return b"".join([chunk async for chunk in content]) + + async def content(self, chunk_size=None) -> AsyncGenerator: + return self._content + + async def close(self) -> None: + self._close = True + + +class TestTimeoutGuard(object): + default_timeout = 1 + + def make_timeout_guard(self, timeout): + return sessions.timeout_guard(timeout) + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_within_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 0.25, 0.75]): + with patch("asyncio.wait_for", lambda coro, _: coro): + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task succeeds. + assert task is True + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_out_of_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 1, 1]): + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task does not succeed and the context manager times out i.e. no remaining time left. + assert task is False + assert exc.match( + f"Context manager exceeded the configured timeout of {self.default_timeout}s." + ) + + @pytest.mark.asyncio + async def test_timeout_with_async_task_timing_out_before_context( + self, simple_async_task + ): + task = False + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): + task = await with_timeout(simple_async_task) + + # Task does not complete i.e. the operation times out. + assert task is False + assert exc.match( + f"The operation {simple_async_task} exceeded the configured timeout of {self.default_timeout}s." + ) + + +class TestAsyncAuthorizedSession(object): + TEST_URL = "http://example.com/" + credentials = AnonymousCredentials() + + @pytest.fixture + async def mocked_content(self): + content = [b"Cavefish ", b"have ", b"no ", b"sight."] + for chunk in content: + yield chunk + + @pytest.mark.asyncio + async def test_constructor_with_default_auth_request(self): + with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", True): + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + assert authed_session._credentials == self.credentials + await authed_session.close() + + @pytest.mark.asyncio + async def test_constructor_with_provided_auth_request(self): + auth_request = MockRequest() + authed_session = sessions.AsyncAuthorizedSession( + self.credentials, auth_request=auth_request + ) + + assert authed_session._auth_request is auth_request + await authed_session.close() + + @pytest.mark.asyncio + async def test_constructor_raises_no_auth_request_error(self): + with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", False): + with pytest.raises(TransportError) as exc: + sessions.AsyncAuthorizedSession(self.credentials) + + exc.match( + "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." + ) + + @pytest.mark.asyncio + async def test_constructor_raises_incorrect_credentials_error(self): + credentials = Mock() + with pytest.raises(InvalidType) as exc: + sessions.AsyncAuthorizedSession(credentials) + + exc.match( + f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`" + ) + + @pytest.mark.asyncio + async def test_request_default_auth_request_success(self): + with aioresponses() as m: + mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] + mocked_response = b"".join(mocked_chunks) + m.get(self.TEST_URL, status=200, body=mocked_response) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + response = await authed_session.request("GET", self.TEST_URL) + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + assert await response.read() == b"Cavefish have no sight." + await response.close() + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_provided_auth_request_success(self, mocked_content): + mocked_response = MockResponse( + status_code=200, + headers={"Content-Type": "application/json"}, + content=mocked_content, + ) + auth_request = MockRequest(mocked_response) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + response = await authed_session.request("GET", self.TEST_URL) + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + assert await response.read() == b"Cavefish have no sight." + await response.close() + assert response._close + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_raises_timeout_error(self): + auth_request = MockRequest(side_effect=asyncio.TimeoutError) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + with pytest.raises(TimeoutError): + await authed_session.request("GET", self.TEST_URL) + + @pytest.mark.asyncio + async def test_request_raises_transport_error(self): + auth_request = MockRequest(side_effect=TransportError) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + with pytest.raises(TransportError): + await authed_session.request("GET", self.TEST_URL) + + @pytest.mark.asyncio + async def test_request_max_allowed_time_exceeded_error(self): + auth_request = MockRequest(side_effect=TransportError) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) + with patch("time.monotonic", side_effect=[0, 1, 1]): + with pytest.raises(TimeoutError): + await authed_session.request("GET", self.TEST_URL, max_allowed_time=1) + + @pytest.mark.parametrize("retry_status", DEFAULT_RETRYABLE_STATUS_CODES) + @pytest.mark.asyncio + async def test_request_max_retries(self, retry_status): + mocked_response = MockResponse(status_code=retry_status) + auth_request = MockRequest(mocked_response) + with patch("asyncio.sleep", return_value=None): + authed_session = sessions.AsyncAuthorizedSession( + self.credentials, auth_request + ) + await authed_session.request("GET", self.TEST_URL) + assert auth_request.call_count == DEFAULT_MAX_RETRY_ATTEMPTS + + @pytest.mark.asyncio + async def test_http_get_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.get(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.get(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_post_method_success(self): + expected_payload = b"content is posted." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.post(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.post(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_put_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.put(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.put(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_patch_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.patch(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.patch(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_delete_method_success(self): + expected_payload = b"content is deleted." + authed_session = sessions.AsyncAuthorizedSession(self.credentials) + with aioresponses() as m: + m.delete(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.delete(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() From afb9e5ac889ec7599976cf3cf8516d17f6b59633 Mon Sep 17 00:00:00 2001 From: sai-sunder-s <4540365+sai-sunder-s@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:35:08 -0700 Subject: [PATCH 3/5] fix: remove token_info call from token refresh path (#1595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove token_info call from token refresh path * update secret * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot --- google/oauth2/credentials.py | 30 --------- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes tests/oauth2/test_credentials.py | 102 +++---------------------------- 3 files changed, 9 insertions(+), 123 deletions(-) diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index a478669cf..6e158089f 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -32,7 +32,6 @@ """ from datetime import datetime -import http.client as http_client import io import json import logging @@ -351,33 +350,6 @@ def with_universe_domain(self, universe_domain): def _metric_header_for_usage(self): return metrics.CRED_TYPE_USER - def _set_account_from_access_token(self, request): - """Obtain the account from token info endpoint and set the account field. - - Args: - request (google.auth.transport.Request): A callable used to make - HTTP requests. - """ - # We only set the account if it's not yet set. - if self._account: - return - - if not self.token: - return - - # Make request to token info endpoint with the access token. - # If the token is invalid, it returns 400 error code. - # If the token is valid, it returns 200 status with a JSON. The account - # is the "email" field of the JSON. - token_info_url = "{}?access_token={}".format( - _GOOGLE_OAUTH2_TOKEN_INFO_ENDPOINT, self.token - ) - response = request(method="GET", url=token_info_url) - - if response.status == http_client.OK: - response_data = json.loads(response.data.decode("utf-8")) - self._account = response_data.get("email") - @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): if self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: @@ -414,7 +386,6 @@ def refresh(self, request): ) self.token = token self.expiry = expiry - self._set_account_from_access_token(request) return if ( @@ -451,7 +422,6 @@ def refresh(self, request): self._refresh_token = refresh_token self._id_token = grant_response.get("id_token") self._rapt_token = rapt_token - self._set_account_from_access_token(request) if scopes and "scope" in grant_response: requested_scopes = frozenset(scopes) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 7f0fd8d86ea09a1a7c44877f3f2bf39ff3509c39..ebddbfe057d8bd05308ce2e6c3eb38013851901e 100644 GIT binary patch literal 10324 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 Date: Tue, 17 Sep 2024 12:17:10 -0400 Subject: [PATCH 4/5] chore: update templated files (#1597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update templated files * remove replacement in owlbot.py * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot --- .github/.OwlBot.lock.yaml | 4 ++-- .kokoro/docker/docs/Dockerfile | 9 ++++----- .kokoro/publish-docs.sh | 20 ++++++++++---------- .kokoro/release.sh | 2 +- .kokoro/release/common.cfg | 2 +- owlbot.py | 7 ------- 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index f30cb3775..597e0c326 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:52210e0e0559f5ea8c52be148b33504022e1faef4e95fbe4b32d68022af2fa7e -# created: 2024-07-08T19:25:35.862283192Z + digest: sha256:e8dcfd7cbfd8beac3a3ff8d3f3185287ea0625d859168cc80faccfc9a7a00455 +# created: 2024-09-16T21:04:09.091105552Z diff --git a/.kokoro/docker/docs/Dockerfile b/.kokoro/docker/docs/Dockerfile index 5205308b3..e5410e296 100644 --- a/.kokoro/docker/docs/Dockerfile +++ b/.kokoro/docker/docs/Dockerfile @@ -72,19 +72,18 @@ RUN tar -xvf Python-3.10.14.tgz RUN ./Python-3.10.14/configure --enable-optimizations RUN make altinstall -RUN python3.10 -m venv /venv -ENV PATH /venv/bin:$PATH +ENV PATH /usr/local/bin/python3.10:$PATH ###################### Install pip RUN wget -O /tmp/get-pip.py 'https://bootstrap.pypa.io/get-pip.py' \ - && python3 /tmp/get-pip.py \ + && python3.10 /tmp/get-pip.py \ && rm /tmp/get-pip.py # Test pip -RUN python3 -m pip +RUN python3.10 -m pip # Install build requirements COPY requirements.txt /requirements.txt -RUN python3 -m pip install --require-hashes -r requirements.txt +RUN python3.10 -m pip install --require-hashes -r requirements.txt CMD ["python3.10"] diff --git a/.kokoro/publish-docs.sh b/.kokoro/publish-docs.sh index 38f083f05..233205d58 100755 --- a/.kokoro/publish-docs.sh +++ b/.kokoro/publish-docs.sh @@ -21,18 +21,18 @@ export PYTHONUNBUFFERED=1 export PATH="${HOME}/.local/bin:${PATH}" # Install nox -python3 -m pip install --require-hashes -r .kokoro/requirements.txt -python3 -m nox --version +python3.10 -m pip install --require-hashes -r .kokoro/requirements.txt +python3.10 -m nox --version # build docs nox -s docs # create metadata -python3 -m docuploader create-metadata \ +python3.10 -m docuploader create-metadata \ --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3 setup.py --version) \ + --version=$(python3.10 setup.py --version) \ --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3 setup.py --name) \ + --distribution-name=$(python3.10 setup.py --name) \ --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) @@ -40,18 +40,18 @@ python3 -m docuploader create-metadata \ cat docs.metadata # upload docs -python3 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket "${STAGING_BUCKET}" +python3.10 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket "${STAGING_BUCKET}" # docfx yaml files nox -s docfx # create metadata. -python3 -m docuploader create-metadata \ +python3.10 -m docuploader create-metadata \ --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3 setup.py --version) \ + --version=$(python3.10 setup.py --version) \ --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3 setup.py --name) \ + --distribution-name=$(python3.10 setup.py --name) \ --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) @@ -59,4 +59,4 @@ python3 -m docuploader create-metadata \ cat docs.metadata # upload docs -python3 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}" +python3.10 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}" diff --git a/.kokoro/release.sh b/.kokoro/release.sh index c22751b98..5b3d28e51 100755 --- a/.kokoro/release.sh +++ b/.kokoro/release.sh @@ -23,7 +23,7 @@ python3 -m releasetool publish-reporter-script > /tmp/publisher-script; source / export PYTHONUNBUFFERED=1 # Move into the package, build the distribution and upload. -TWINE_PASSWORD=$(cat "${KOKORO_KEYSTORE_DIR}/73713_google-cloud-pypi-token-keystore-1") +TWINE_PASSWORD=$(cat "${KOKORO_KEYSTORE_DIR}/73713_google-cloud-pypi-token-keystore-2") cd github/google-auth-library-python python3 setup.py sdist bdist_wheel twine upload --username __token__ --password "${TWINE_PASSWORD}" dist/* diff --git a/.kokoro/release/common.cfg b/.kokoro/release/common.cfg index f1d7175f6..062ad3aa9 100644 --- a/.kokoro/release/common.cfg +++ b/.kokoro/release/common.cfg @@ -28,7 +28,7 @@ before_action { fetch_keystore { keystore_resource { keystore_config_id: 73713 - keyname: "google-cloud-pypi-token-keystore-1" + keyname: "google-cloud-pypi-token-keystore-2" } } } diff --git a/owlbot.py b/owlbot.py index 99fcc3898..c2cd0fc20 100644 --- a/owlbot.py +++ b/owlbot.py @@ -26,11 +26,4 @@ templated_files / "renovate.json", ) - -assert 1 == s.replace( - ".kokoro/docs/docs-presubmit.cfg", - 'value: "docs docfx"', - 'value: "docs"', -) - s.shell.run(["nox", "-s", "blacken"], hide_output=False) From 05ddedfab046ed8f1d42349e784f7c611c6834fe Mon Sep 17 00:00:00 2001 From: ohmayr Date: Tue, 17 Sep 2024 17:35:53 +0000 Subject: [PATCH 5/5] chore(main): pre-release 2.35.0rc0 --- CHANGELOG.md | 13 +++++++++++++ google/auth/version.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0495420e..9859445f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ [1]: https://pypi.org/project/google-auth/#history +## [2.35.0rc0](https://github.com/googleapis/google-auth-library-python/compare/v2.34.0...v2.35.0rc0) (2024-09-17) + + +### Features + +* Add cred info to ADC creds ([#1587](https://github.com/googleapis/google-auth-library-python/issues/1587)) ([6f75dd5](https://github.com/googleapis/google-auth-library-python/commit/6f75dd5de9ee1da4509306ff2e6420b3d88f9d00)) +* Add support for asynchronous `AuthorizedSession` api ([#1577](https://github.com/googleapis/google-auth-library-python/issues/1577)) ([2910b6b](https://github.com/googleapis/google-auth-library-python/commit/2910b6b56f8b82ad6b2e78befb7d0b3fbe96042d)) + + +### Bug Fixes + +* Remove token_info call from token refresh path ([#1595](https://github.com/googleapis/google-auth-library-python/issues/1595)) ([afb9e5a](https://github.com/googleapis/google-auth-library-python/commit/afb9e5ac889ec7599976cf3cf8516d17f6b59633)) + ## [2.34.0](https://github.com/googleapis/google-auth-library-python/compare/v2.33.0...v2.34.0) (2024-08-13) diff --git a/google/auth/version.py b/google/auth/version.py index 297e18a45..d8d0cc129 100644 --- a/google/auth/version.py +++ b/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.34.0" +__version__ = "2.35.0rc0"