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] 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