From 41599ae932db31cf692d2c7bec15d8bb1778ab50 Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Wed, 2 Sep 2020 12:55:42 -0600 Subject: [PATCH 01/35] refactor: split 'with_quota_project' into separate base class (#561) Co-authored-by: Tres Seaver --- google/auth/app_engine.py | 6 ++++-- google/auth/compute_engine/credentials.py | 8 ++++---- google/auth/credentials.py | 9 +++++---- google/auth/impersonated_credentials.py | 8 ++++---- google/auth/jwt.py | 10 ++++++---- google/oauth2/credentials.py | 8 ++++---- google/oauth2/service_account.py | 10 ++++++---- tests/test__default.py | 2 +- tests/test_credentials.py | 6 ------ 9 files changed, 34 insertions(+), 33 deletions(-) diff --git a/google/auth/app_engine.py b/google/auth/app_engine.py index fae00d0b8..f1d21280e 100644 --- a/google/auth/app_engine.py +++ b/google/auth/app_engine.py @@ -77,7 +77,9 @@ def get_project_id(): return app_identity.get_application_id() -class Credentials(credentials.Scoped, credentials.Signing, credentials.Credentials): +class Credentials( + credentials.Scoped, credentials.Signing, credentials.CredentialsWithQuotaProject +): """App Engine standard environment credentials. These credentials use the App Engine App Identity API to obtain access @@ -145,7 +147,7 @@ def with_scopes(self, scopes): quota_project_id=self.quota_project_id, ) - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( scopes=self._scopes, diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index e6da238a0..b7fca1832 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -32,7 +32,7 @@ from google.oauth2 import _client -class Credentials(credentials.ReadOnlyScoped, credentials.Credentials): +class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject): """Compute Engine Credentials. These credentials use the Google Compute Engine metadata server to obtain @@ -118,7 +118,7 @@ def requires_scopes(self): """False: Compute Engine credentials can not be scoped.""" return False - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( service_account_email=self._service_account_email, @@ -130,7 +130,7 @@ def with_quota_project(self, quota_project_id): _DEFAULT_TOKEN_URI = "https://www.googleapis.com/oauth2/v4/token" -class IDTokenCredentials(credentials.Credentials, credentials.Signing): +class IDTokenCredentials(credentials.CredentialsWithQuotaProject, credentials.Signing): """Open ID Connect ID Token-based service account credentials. These credentials relies on the default service account of a GCE instance. @@ -254,7 +254,7 @@ def with_target_audience(self, target_audience): quota_project_id=self._quota_project_id, ) - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): # since the signer is already instantiated, diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 3f389b171..5ea36a0ba 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -133,6 +133,10 @@ def before_request(self, request, method, url, headers): self.refresh(request) self.apply(headers) + +class CredentialsWithQuotaProject(Credentials): + """Abstract base for credentials supporting ``with_quota_project`` factory""" + def with_quota_project(self, quota_project_id): """Returns a copy of these credentials with a modified quota project @@ -143,7 +147,7 @@ def with_quota_project(self, quota_project_id): Returns: google.oauth2.credentials.Credentials: A new credentials instance. """ - raise NotImplementedError("This class does not support quota project.") + raise NotImplementedError("This credential does not support quota project.") class AnonymousCredentials(Credentials): @@ -182,9 +186,6 @@ def apply(self, headers, token=None): def before_request(self, request, method, url, headers): """Anonymous credentials do nothing to the request.""" - def with_quota_project(self, quota_project_id): - raise ValueError("Anonymous credentials don't support quota project.") - @six.add_metaclass(abc.ABCMeta) class ReadOnlyScoped(object): diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index dbcb2914e..d2c5ded1c 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -115,7 +115,7 @@ def _make_iam_token_request(request, principal, headers, body): six.raise_from(new_exc, caught_exc) -class Credentials(credentials.Credentials, credentials.Signing): +class Credentials(credentials.CredentialsWithQuotaProject, credentials.Signing): """This module defines impersonated credentials which are essentially impersonated identities. @@ -293,7 +293,7 @@ def service_account_email(self): def signer(self): return self - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( self._source_credentials, @@ -305,7 +305,7 @@ def with_quota_project(self, quota_project_id): ) -class IDTokenCredentials(credentials.Credentials): +class IDTokenCredentials(credentials.CredentialsWithQuotaProject): """Open ID Connect ID Token-based service account credentials. """ @@ -359,7 +359,7 @@ def with_include_email(self, include_email): quota_project_id=self._quota_project_id, ) - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( target_credentials=self._target_credentials, diff --git a/google/auth/jwt.py b/google/auth/jwt.py index 35ae03432..a4f04f529 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -288,7 +288,9 @@ def decode(token, certs=None, verify=True, audience=None): return payload -class Credentials(google.auth.credentials.Signing, google.auth.credentials.Credentials): +class Credentials( + google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject +): """Credentials that use a JWT as the bearer token. These credentials require an "audience" claim. This claim identifies the @@ -493,7 +495,7 @@ def with_claims( quota_project_id=self._quota_project_id, ) - @_helpers.copy_docstring(google.auth.credentials.Credentials) + @_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( self._signer, @@ -554,7 +556,7 @@ def signer(self): class OnDemandCredentials( - google.auth.credentials.Signing, google.auth.credentials.Credentials + google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject ): """On-demand JWT credentials. @@ -721,7 +723,7 @@ def with_claims(self, issuer=None, subject=None, additional_claims=None): quota_project_id=self._quota_project_id, ) - @_helpers.copy_docstring(google.auth.credentials.Credentials) + @_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 6f9627572..6e58f630d 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -47,7 +47,7 @@ _GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" -class Credentials(credentials.ReadOnlyScoped, credentials.Credentials): +class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject): """Credentials using OAuth 2.0 access and refresh tokens. The credentials are considered immutable. If you want to modify the @@ -161,7 +161,7 @@ def requires_scopes(self): the initial token is requested and can not be changed.""" return False - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( @@ -305,7 +305,7 @@ def to_json(self, strip=None): return json.dumps(prep) -class UserAccessTokenCredentials(credentials.Credentials): +class UserAccessTokenCredentials(credentials.CredentialsWithQuotaProject): """Access token credentials for user account. Obtain the access token for a given user account or the current active @@ -336,7 +336,7 @@ def with_account(self, account): """ return self.__class__(account=account, quota_project_id=self._quota_project_id) - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__(account=self._account, quota_project_id=quota_project_id) diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 2240631e9..c4898a247 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -82,7 +82,9 @@ _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds -class Credentials(credentials.Signing, credentials.Scoped, credentials.Credentials): +class Credentials( + credentials.Signing, credentials.Scoped, credentials.CredentialsWithQuotaProject +): """Service account credentials Usually, you'll create these credentials with one of the helper @@ -306,7 +308,7 @@ def with_claims(self, additional_claims): additional_claims=new_additional_claims, ) - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( @@ -375,7 +377,7 @@ def signer_email(self): return self._service_account_email -class IDTokenCredentials(credentials.Signing, credentials.Credentials): +class IDTokenCredentials(credentials.Signing, credentials.CredentialsWithQuotaProject): """Open ID Connect ID Token-based service account credentials. These credentials are largely similar to :class:`.Credentials`, but instead @@ -533,7 +535,7 @@ def with_target_audience(self, target_audience): quota_project_id=self.quota_project_id, ) - @_helpers.copy_docstring(credentials.Credentials) + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( self._signer, diff --git a/tests/test__default.py b/tests/test__default.py index 55a14c207..2738e22bc 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -49,7 +49,7 @@ with open(SERVICE_ACCOUNT_FILE) as fh: SERVICE_ACCOUNT_FILE_DATA = json.load(fh) -MOCK_CREDENTIALS = mock.Mock(spec=credentials.Credentials) +MOCK_CREDENTIALS = mock.Mock(spec=credentials.CredentialsWithQuotaProject) MOCK_CREDENTIALS.with_quota_project.return_value = MOCK_CREDENTIALS LOAD_FILE_PATCH = mock.patch( diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 2023fac1b..0637b01e4 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -115,12 +115,6 @@ def test_anonymous_credentials_before_request(): assert headers == {} -def test_anonymous_credentials_with_quota_project(): - with pytest.raises(ValueError): - anon = credentials.AnonymousCredentials() - anon.with_quota_project("project-foo") - - class ReadOnlyScopedCredentialsImpl(credentials.ReadOnlyScoped, CredentialsImpl): @property def requires_scopes(self): From d32f7df4895122ef23b664672d7db3f58d9b7d36 Mon Sep 17 00:00:00 2001 From: arithmetic1728 <58957152+arithmetic1728@users.noreply.github.com> Date: Thu, 3 Sep 2020 09:55:20 -0700 Subject: [PATCH 02/35] fix: dummy commit to trigger a auto release (#597) --- google/auth/credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 5ea36a0ba..bc42546b9 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -138,7 +138,7 @@ class CredentialsWithQuotaProject(Credentials): """Abstract base for credentials supporting ``with_quota_project`` factory""" def with_quota_project(self, quota_project_id): - """Returns a copy of these credentials with a modified quota project + """Returns a copy of these credentials with a modified quota project. Args: quota_project_id (str): The project to use for quota and From 892dc37ea7941c716171372be89ee3387b6c2715 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2020 10:08:05 -0700 Subject: [PATCH 03/35] chore: release 1.21.1 (#599) * chore: updated CHANGELOG.md [ci skip] * chore: updated setup.cfg [ci skip] * chore: updated setup.py Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21519f2de..0b4c774eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-auth/#history +### [1.21.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.0...v1.21.1) (2020-09-03) + + +### Bug Fixes + +* dummy commit to trigger a auto release ([#597](https://www.github.com/googleapis/google-auth-library-python/issues/597)) ([d32f7df](https://www.github.com/googleapis/google-auth-library-python/commit/d32f7df4895122ef23b664672d7db3f58d9b7d36)) + ## [1.21.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.20.1...v1.21.0) (2020-08-27) diff --git a/setup.py b/setup.py index c0365391e..c1218dbf7 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.21.0" +version = "1.21.1" setup( name="google-auth", From 694d83fd23c0e8c2fde27136d1b3f8f6db6338a6 Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Tue, 8 Sep 2020 15:28:08 -0600 Subject: [PATCH 04/35] fix: migrate signBlob to iamcredentials.googleapis.com (#600) Migrate signBlob from iam.googleapis.com to iamcredentials.googleapis.com. This API is deprecated and will be shutdown in one year. This is used google.auth.iam.Signer. Added a system_test to sanity check the implementation. --- .gitignore | 3 +++ google/auth/iam.py | 6 +++--- system_tests/test_service_account.py | 17 +++++++++++++++++ tests/compute_engine/test_credentials.py | 12 ++++++------ tests/test_iam.py | 2 +- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 6d86d1f7a..f01e60ec0 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ pylintrc.test pytype_output/ .python-version +.DS_Store +cert_path +key_path \ No newline at end of file diff --git a/google/auth/iam.py b/google/auth/iam.py index d83b25180..5e88a0435 100644 --- a/google/auth/iam.py +++ b/google/auth/iam.py @@ -28,7 +28,7 @@ from google.auth import crypt from google.auth import exceptions -_IAM_API_ROOT_URI = "https://iam.googleapis.com/v1" +_IAM_API_ROOT_URI = "https://iamcredentials.googleapis.com/v1" _SIGN_BLOB_URI = _IAM_API_ROOT_URI + "/projects/-/serviceAccounts/{}:signBlob?alt=json" @@ -71,7 +71,7 @@ def _make_signing_request(self, message): url = _SIGN_BLOB_URI.format(self._service_account_email) headers = {"Content-Type": "application/json"} body = json.dumps( - {"bytesToSign": base64.b64encode(message).decode("utf-8")} + {"payload": base64.b64encode(message).decode("utf-8")} ).encode("utf-8") self._credentials.before_request(self._request, method, url, headers) @@ -97,4 +97,4 @@ def key_id(self): @_helpers.copy_docstring(crypt.Signer) def sign(self, message): response = self._make_signing_request(message) - return base64.b64decode(response["signature"]) + return base64.b64decode(response["signedBlob"]) diff --git a/system_tests/test_service_account.py b/system_tests/test_service_account.py index 262ce84f5..498b75b22 100644 --- a/system_tests/test_service_account.py +++ b/system_tests/test_service_account.py @@ -16,6 +16,7 @@ from google.auth import _helpers from google.auth import exceptions +from google.auth import iam from google.oauth2 import service_account @@ -46,3 +47,19 @@ def test_refresh_success(http_request, credentials, token_info): "https://www.googleapis.com/auth/userinfo.profile", ] ) + +def test_iam_signer(http_request, credentials): + credentials = credentials.with_scopes( + ["https://www.googleapis.com/auth/iam"] + ) + + # Verify iamcredentials signer. + signer = iam.Signer( + http_request, + credentials, + credentials.service_account_email + ) + + signed_blob = signer.sign("message") + + assert isinstance(signed_blob, bytes) diff --git a/tests/compute_engine/test_credentials.py b/tests/compute_engine/test_credentials.py index 8c95e2437..4ee653676 100644 --- a/tests/compute_engine/test_credentials.py +++ b/tests/compute_engine/test_credentials.py @@ -363,11 +363,11 @@ def test_with_target_audience_integration(self): signature = base64.b64encode(b"some-signature").decode("utf-8") responses.add( responses.POST, - "https://iam.googleapis.com/v1/projects/-/serviceAccounts/" - "service-account@example.com:signBlob?alt=json", + "https://iamcredentials.googleapis.com/v1/projects/-/" + "serviceAccounts/service-account@example.com:signBlob?alt=json", status=200, content_type="application/json", - json={"keyId": "some-key-id", "signature": signature}, + json={"keyId": "some-key-id", "signedBlob": signature}, ) id_token = "{}.{}.{}".format( @@ -477,11 +477,11 @@ def test_with_quota_project_integration(self): signature = base64.b64encode(b"some-signature").decode("utf-8") responses.add( responses.POST, - "https://iam.googleapis.com/v1/projects/-/serviceAccounts/" - "service-account@example.com:signBlob?alt=json", + "https://iamcredentials.googleapis.com/v1/projects/-/" + "serviceAccounts/service-account@example.com:signBlob?alt=json", status=200, content_type="application/json", - json={"keyId": "some-key-id", "signature": signature}, + json={"keyId": "some-key-id", "signedBlob": signature}, ) id_token = "{}.{}.{}".format( diff --git a/tests/test_iam.py b/tests/test_iam.py index cb2c26f73..fbd3e418d 100644 --- a/tests/test_iam.py +++ b/tests/test_iam.py @@ -81,7 +81,7 @@ def test_key_id(self): def test_sign_bytes(self): signature = b"DEADBEEF" encoded_signature = base64.b64encode(signature).decode("utf-8") - request = make_request(http_client.OK, data={"signature": encoded_signature}) + request = make_request(http_client.OK, data={"signedBlob": encoded_signature}) credentials = make_credentials() signer = iam.Signer(request, credentials, mock.sentinel.service_account_email) From b921a0ad5be5396fdb07fd618ae886140d1df318 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2020 18:32:41 -0600 Subject: [PATCH 05/35] chore: release 1.21.2 (#601) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4c774eb..78863ffed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-auth/#history +### [1.21.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.1...v1.21.2) (2020-09-08) + + +### Bug Fixes + +* migrate signBlob to iamcredentials.googleapis.com ([#600](https://www.github.com/googleapis/google-auth-library-python/issues/600)) ([694d83f](https://www.github.com/googleapis/google-auth-library-python/commit/694d83fd23c0e8c2fde27136d1b3f8f6db6338a6)) + ### [1.21.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.0...v1.21.1) (2020-09-03) diff --git a/setup.py b/setup.py index c1218dbf7..ea43594c0 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.21.1" +version = "1.21.2" setup( name="google-auth", From d0e0aba0a9f665268ffa1b22d44f4bd7e9b449d6 Mon Sep 17 00:00:00 2001 From: wesley chun Date: Thu, 17 Sep 2020 09:18:55 -0700 Subject: [PATCH 06/35] fix: fix expiry for `to_json()` (#589) * This patch for includes the following fixes: - The access token is always set to `None`, so the fix involves using (the access) `token` from the saved JSON credentials file. - For refresh needs, `expiry` also needs to be saved via `to_json()`. - DUMP: As `expiry` is a `datetime.datetime` object, serialize to `datetime.isoformat()` in the same [`oauth2client` format](https://github.com/googleapis/oauth2client/blob/master/oauth2client/client.py#L55) for consistency. - LOAD: Add code to restore `expiry` back to `datetime.datetime` object when imported. - LOAD: If `expiry` was unsaved, automatically set it as expired so refresh takes place. - Minor `scopes` updates - DUMP: Add property for `scopes` so `to_json()` can grab it - LOAD: `scopes` may be saved as a string instead of a JSON array (Python list), so ensure it is Sequence[str] when imported. --- google/oauth2/credentials.py | 43 ++++++++++++++++++++++++-------- tests/oauth2/test_credentials.py | 24 ++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 6e58f630d..36b8f0cb7 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -31,6 +31,7 @@ .. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1 """ +from datetime import datetime import io import json @@ -66,6 +67,7 @@ def __init__( client_secret=None, scopes=None, quota_project_id=None, + expiry=None, ): """ Args: @@ -95,6 +97,7 @@ def __init__( """ super(Credentials, self).__init__() self.token = token + self.expiry = expiry self._refresh_token = refresh_token self._id_token = id_token self._scopes = scopes @@ -128,6 +131,11 @@ def refresh_token(self): """Optional[str]: The OAuth 2.0 refresh token.""" return self._refresh_token + @property + def scopes(self): + """Optional[str]: The OAuth 2.0 permission scopes.""" + return self._scopes + @property def token_uri(self): """Optional[str]: The OAuth 2.0 authorization server's token endpoint @@ -241,16 +249,30 @@ def from_authorized_user_info(cls, info, scopes=None): "fields {}.".format(", ".join(missing)) ) + # access token expiry (datetime obj); auto-expire if not saved + expiry = info.get("expiry") + if expiry: + expiry = datetime.strptime( + expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S" + ) + else: + expiry = _helpers.utcnow() - _helpers.CLOCK_SKEW + + # process scopes, which needs to be a seq + if scopes is None and "scopes" in info: + scopes = info.get("scopes") + if isinstance(scopes, str): + scopes = scopes.split(" ") + return cls( - None, # No access token, must be refreshed. - refresh_token=info["refresh_token"], - token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT, + token=info.get("token"), + refresh_token=info.get("refresh_token"), + token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT, # always overrides scopes=scopes, - client_id=info["client_id"], - client_secret=info["client_secret"], - quota_project_id=info.get( - "quota_project_id" - ), # quota project may not exist + client_id=info.get("client_id"), + client_secret=info.get("client_secret"), + quota_project_id=info.get("quota_project_id"), # may not exist + expiry=expiry, ) @classmethod @@ -294,8 +316,10 @@ def to_json(self, strip=None): "client_secret": self.client_secret, "scopes": self.scopes, } + if self.expiry: # flatten expiry timestamp + prep["expiry"] = self.expiry.isoformat() + "Z" - # Remove empty entries + # Remove empty entries (those which are None) prep = {k: v for k, v in prep.items() if v is not None} # Remove entries that explicitely need to be removed @@ -316,7 +340,6 @@ class UserAccessTokenCredentials(credentials.CredentialsWithQuotaProject): specified, the current active account will be used. quota_project_id (Optional[str]): The project ID used for quota and billing. - """ def __init__(self, account=None, quota_project_id=None): diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index ceb8cdfd5..ee8b8a211 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -359,6 +359,20 @@ def test_from_authorized_user_info(self): assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT assert creds.scopes == scopes + info["scopes"] = "email" # single non-array scope from file + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.scopes == [info["scopes"]] + + info["scopes"] = ["email", "profile"] # array scope from file + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.scopes == info["scopes"] + + expiry = datetime.datetime(2020, 8, 14, 15, 54, 1) + info["expiry"] = expiry.isoformat() + "Z" + creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.expiry == expiry + assert creds.expired + def test_from_authorized_user_file(self): info = AUTH_USER_INFO.copy() @@ -381,7 +395,10 @@ def test_from_authorized_user_file(self): def test_to_json(self): info = AUTH_USER_INFO.copy() + expiry = datetime.datetime(2020, 8, 14, 15, 54, 1) + info["expiry"] = expiry.isoformat() + "Z" creds = credentials.Credentials.from_authorized_user_info(info) + assert creds.expiry == expiry # Test with no `strip` arg json_output = creds.to_json() @@ -392,6 +409,7 @@ def test_to_json(self): assert json_asdict.get("client_id") == creds.client_id assert json_asdict.get("scopes") == creds.scopes assert json_asdict.get("client_secret") == creds.client_secret + assert json_asdict.get("expiry") == info["expiry"] # Test with a `strip` arg json_output = creds.to_json(strip=["client_secret"]) @@ -403,6 +421,12 @@ def test_to_json(self): assert json_asdict.get("scopes") == creds.scopes assert json_asdict.get("client_secret") is None + # Test with no expiry + creds.expiry = None + json_output = creds.to_json() + json_asdict = json.loads(json_output) + assert json_asdict.get("expiry") is None + def test_pickle_and_unpickle(self): creds = self.make_credentials() unpickled = pickle.loads(pickle.dumps(creds)) From da3526f877f288d6983847917a081858992c821f Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Tue, 22 Sep 2020 16:11:44 -0600 Subject: [PATCH 07/35] chore: add default CODEOWNERS (#609) --- .github/CODEOWNERS | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..30c3973aa --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# Code owners file. +# This file controls who is tagged for review for any given pull request. +# +# For syntax help see: +# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax + +# The @googleapis/yoshi-python is the default owner for changes in this repo +* @googleapis/yoshi-python + +# The python-samples-reviewers team is the default owner for samples changes +/samples/ @googleapis/python-samples-owners \ No newline at end of file From cc91e752137014bb7f10d1664b905aa317a1cd11 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2020 12:31:17 -0600 Subject: [PATCH 08/35] chore: release 1.21.3 (#607) --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78863ffed..d57047990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-auth/#history +### [1.21.3](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.2...v1.21.3) (2020-09-22) + + +### Bug Fixes + +* fix expiry for `to_json()` ([#589](https://www.github.com/googleapis/google-auth-library-python/issues/589)) ([d0e0aba](https://www.github.com/googleapis/google-auth-library-python/commit/d0e0aba0a9f665268ffa1b22d44f4bd7e9b449d6)), closes [/github.com/googleapis/oauth2client/blob/master/oauth2client/client.py#L55](https://www.github.com/googleapis//github.com/googleapis/oauth2client/blob/master/oauth2client/client.py/issues/L55) + ### [1.21.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.1...v1.21.2) (2020-09-08) diff --git a/setup.py b/setup.py index ea43594c0..1c3578f60 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.21.2" +version = "1.21.3" setup( name="google-auth", From 7e1525822d51bd9ce7dffca42d71313e6e776fcd Mon Sep 17 00:00:00 2001 From: Christopher Wilcox Date: Mon, 28 Sep 2020 15:27:20 -0700 Subject: [PATCH 09/35] feat: add asyncio based auth flow (#612) * feat: asyncio http request logic and asynchronous credentials logic (#572) Co-authored-by: Anirudh Baddepudi <43104821+anibadde@users.noreply.github.com> --- .../google.auth.credentials_async.rst | 7 + docs/reference/google.auth.jwt_async.rst | 7 + docs/reference/google.auth.rst | 2 + ...google.auth.transport.aiohttp_requests.rst | 7 + docs/reference/google.auth.transport.rst | 1 + .../google.oauth2.credentials_async.rst | 7 + docs/reference/google.oauth2.rst | 2 + .../google.oauth2.service_account_async.rst | 7 + google/auth/__init__.py | 2 - google/auth/_credentials_async.py | 176 +++++++ google/auth/_default_async.py | 266 ++++++++++ google/auth/_jwt_async.py | 168 ++++++ google/auth/transport/_aiohttp_requests.py | 384 ++++++++++++++ google/auth/transport/mtls.py | 9 +- google/oauth2/_client_async.py | 264 ++++++++++ google/oauth2/_credentials_async.py | 108 ++++ google/oauth2/_id_token_async.py | 267 ++++++++++ google/oauth2/_service_account_async.py | 132 +++++ noxfile.py | 40 +- setup.py | 1 + system_tests/noxfile.py | 154 ++++-- system_tests/system_tests_async/conftest.py | 108 ++++ .../system_tests_async/test_default.py | 30 ++ .../system_tests_async/test_id_token.py | 25 + .../test_service_account.py | 53 ++ .../{ => system_tests_sync}/.gitignore | 0 .../{ => system_tests_sync}/__init__.py | 0 .../app_engine_test_app/.gitignore | 0 .../app_engine_test_app/app.yaml | 0 .../app_engine_test_app/appengine_config.py | 0 .../app_engine_test_app/main.py | 0 .../app_engine_test_app/requirements.txt | 0 .../{ => system_tests_sync}/conftest.py | 3 +- .../{ => system_tests_sync}/secrets.tar.enc | Bin .../test_app_engine.py | 0 .../test_compute_engine.py | 0 .../{ => system_tests_sync}/test_default.py | 0 .../{ => system_tests_sync}/test_grpc.py | 0 .../{ => system_tests_sync}/test_id_token.py | 0 .../test_impersonated_credentials.py | 0 .../{ => system_tests_sync}/test_mtls_http.py | 0 .../test_oauth2_credentials.py | 0 .../test_service_account.py | 0 tests_async/__init__.py | 0 tests_async/conftest.py | 51 ++ tests_async/oauth2/test__client_async.py | 297 +++++++++++ tests_async/oauth2/test_credentials_async.py | 478 ++++++++++++++++++ tests_async/oauth2/test_id_token.py | 205 ++++++++ .../oauth2/test_service_account_async.py | 372 ++++++++++++++ tests_async/test__default_async.py | 468 +++++++++++++++++ tests_async/test_credentials_async.py | 177 +++++++ tests_async/test_jwt_async.py | 356 +++++++++++++ tests_async/transport/__init__.py | 0 tests_async/transport/async_compliance.py | 133 +++++ .../transport/test_aiohttp_requests.py | 245 +++++++++ 55 files changed, 4949 insertions(+), 63 deletions(-) create mode 100644 docs/reference/google.auth.credentials_async.rst create mode 100644 docs/reference/google.auth.jwt_async.rst create mode 100644 docs/reference/google.auth.transport.aiohttp_requests.rst create mode 100644 docs/reference/google.oauth2.credentials_async.rst create mode 100644 docs/reference/google.oauth2.service_account_async.rst create mode 100644 google/auth/_credentials_async.py create mode 100644 google/auth/_default_async.py create mode 100644 google/auth/_jwt_async.py create mode 100644 google/auth/transport/_aiohttp_requests.py create mode 100644 google/oauth2/_client_async.py create mode 100644 google/oauth2/_credentials_async.py create mode 100644 google/oauth2/_id_token_async.py create mode 100644 google/oauth2/_service_account_async.py create mode 100644 system_tests/system_tests_async/conftest.py create mode 100644 system_tests/system_tests_async/test_default.py create mode 100644 system_tests/system_tests_async/test_id_token.py create mode 100644 system_tests/system_tests_async/test_service_account.py rename system_tests/{ => system_tests_sync}/.gitignore (100%) rename system_tests/{ => system_tests_sync}/__init__.py (100%) rename system_tests/{ => system_tests_sync}/app_engine_test_app/.gitignore (100%) rename system_tests/{ => system_tests_sync}/app_engine_test_app/app.yaml (100%) rename system_tests/{ => system_tests_sync}/app_engine_test_app/appengine_config.py (100%) rename system_tests/{ => system_tests_sync}/app_engine_test_app/main.py (100%) rename system_tests/{ => system_tests_sync}/app_engine_test_app/requirements.txt (100%) rename system_tests/{ => system_tests_sync}/conftest.py (96%) rename system_tests/{ => system_tests_sync}/secrets.tar.enc (100%) rename system_tests/{ => system_tests_sync}/test_app_engine.py (100%) rename system_tests/{ => system_tests_sync}/test_compute_engine.py (100%) rename system_tests/{ => system_tests_sync}/test_default.py (100%) rename system_tests/{ => system_tests_sync}/test_grpc.py (100%) rename system_tests/{ => system_tests_sync}/test_id_token.py (100%) rename system_tests/{ => system_tests_sync}/test_impersonated_credentials.py (100%) rename system_tests/{ => system_tests_sync}/test_mtls_http.py (100%) rename system_tests/{ => system_tests_sync}/test_oauth2_credentials.py (100%) rename system_tests/{ => system_tests_sync}/test_service_account.py (100%) create mode 100644 tests_async/__init__.py create mode 100644 tests_async/conftest.py create mode 100644 tests_async/oauth2/test__client_async.py create mode 100644 tests_async/oauth2/test_credentials_async.py create mode 100644 tests_async/oauth2/test_id_token.py create mode 100644 tests_async/oauth2/test_service_account_async.py create mode 100644 tests_async/test__default_async.py create mode 100644 tests_async/test_credentials_async.py create mode 100644 tests_async/test_jwt_async.py create mode 100644 tests_async/transport/__init__.py create mode 100644 tests_async/transport/async_compliance.py create mode 100644 tests_async/transport/test_aiohttp_requests.py diff --git a/docs/reference/google.auth.credentials_async.rst b/docs/reference/google.auth.credentials_async.rst new file mode 100644 index 000000000..683139aa5 --- /dev/null +++ b/docs/reference/google.auth.credentials_async.rst @@ -0,0 +1,7 @@ +google.auth.credentials\_async module +===================================== + +.. automodule:: google.auth._credentials_async + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.jwt_async.rst b/docs/reference/google.auth.jwt_async.rst new file mode 100644 index 000000000..4e56a6ea3 --- /dev/null +++ b/docs/reference/google.auth.jwt_async.rst @@ -0,0 +1,7 @@ +google.auth.jwt\_async module +============================= + +.. automodule:: google.auth.jwt_async + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.rst b/docs/reference/google.auth.rst index cfcf70357..3acf7dfb8 100644 --- a/docs/reference/google.auth.rst +++ b/docs/reference/google.auth.rst @@ -24,8 +24,10 @@ Submodules google.auth.app_engine google.auth.credentials + google.auth._credentials_async google.auth.environment_vars google.auth.exceptions google.auth.iam google.auth.impersonated_credentials google.auth.jwt + google.auth.jwt_async diff --git a/docs/reference/google.auth.transport.aiohttp_requests.rst b/docs/reference/google.auth.transport.aiohttp_requests.rst new file mode 100644 index 000000000..44fc4e577 --- /dev/null +++ b/docs/reference/google.auth.transport.aiohttp_requests.rst @@ -0,0 +1,7 @@ +google.auth.transport.aiohttp\_requests module +============================================== + +.. automodule:: google.auth.transport._aiohttp_requests + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.transport.rst b/docs/reference/google.auth.transport.rst index 89218632b..f1d198855 100644 --- a/docs/reference/google.auth.transport.rst +++ b/docs/reference/google.auth.transport.rst @@ -12,6 +12,7 @@ Submodules .. toctree:: :maxdepth: 4 + google.auth.transport._aiohttp_requests google.auth.transport.grpc google.auth.transport.mtls google.auth.transport.requests diff --git a/docs/reference/google.oauth2.credentials_async.rst b/docs/reference/google.oauth2.credentials_async.rst new file mode 100644 index 000000000..d0df1e8a3 --- /dev/null +++ b/docs/reference/google.oauth2.credentials_async.rst @@ -0,0 +1,7 @@ +google.oauth2.credentials\_async module +======================================= + +.. automodule:: google.oauth2._credentials_async + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.oauth2.rst b/docs/reference/google.oauth2.rst index 1ac9c7320..6f3ba50c2 100644 --- a/docs/reference/google.oauth2.rst +++ b/docs/reference/google.oauth2.rst @@ -13,5 +13,7 @@ Submodules :maxdepth: 4 google.oauth2.credentials + google.oauth2._credentials_async google.oauth2.id_token google.oauth2.service_account + google.oauth2._service_account_async diff --git a/docs/reference/google.oauth2.service_account_async.rst b/docs/reference/google.oauth2.service_account_async.rst new file mode 100644 index 000000000..8aba0d851 --- /dev/null +++ b/docs/reference/google.oauth2.service_account_async.rst @@ -0,0 +1,7 @@ +google.oauth2.service\_account\_async module +============================================ + +.. automodule:: google.oauth2._service_account_async + :members: + :inherited-members: + :show-inheritance: diff --git a/google/auth/__init__.py b/google/auth/__init__.py index 5ca20a362..22d61c66f 100644 --- a/google/auth/__init__.py +++ b/google/auth/__init__.py @@ -18,9 +18,7 @@ from google.auth._default import default, load_credentials_from_file - __all__ = ["default", "load_credentials_from_file"] - # Set default logging handler to avoid "No handler found" warnings. logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/google/auth/_credentials_async.py b/google/auth/_credentials_async.py new file mode 100644 index 000000000..d4d4e2c0e --- /dev/null +++ b/google/auth/_credentials_async.py @@ -0,0 +1,176 @@ +# Copyright 2020 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. + + +"""Interfaces for credentials.""" + +import abc +import inspect + +import six + +from google.auth import credentials + + +@six.add_metaclass(abc.ABCMeta) +class Credentials(credentials.Credentials): + """Async inherited credentials class from google.auth.credentials. + The added functionality is the before_request call which requires + async/await syntax. + All credentials have a :attr:`token` that is used for authentication and + may also optionally set an :attr:`expiry` to indicate when the token will + no longer be valid. + + Most credentials will be :attr:`invalid` until :meth:`refresh` is called. + Credentials can do this automatically before the first HTTP request in + :meth:`before_request`. + + Although the token and expiration will change as the credentials are + :meth:`refreshed ` and used, credentials should be considered + immutable. Various credentials will accept configuration such as private + keys, scopes, and other options. These options are not changeable after + construction. Some classes will provide mechanisms to copy the credentials + with modifications such as :meth:`ScopedCredentials.with_scopes`. + """ + + async def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the authentication header. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + method (str): The request's HTTP method or the RPC method being + invoked. + url (str): The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (Subclasses may use these arguments to ascertain information about + # the http request.) + + if not self.valid: + if inspect.iscoroutinefunction(self.refresh): + await self.refresh(request) + else: + self.refresh(request) + self.apply(headers) + + +class CredentialsWithQuotaProject(credentials.CredentialsWithQuotaProject): + """Abstract base for credentials supporting ``with_quota_project`` factory""" + + +class AnonymousCredentials(credentials.AnonymousCredentials, Credentials): + """Credentials that do not provide any authentication information. + + These are useful in the case of services that support anonymous access or + local service emulators that do not use credentials. This class inherits + from the sync anonymous credentials file, but is kept if async credentials + is initialized and we would like anonymous credentials. + """ + + +@six.add_metaclass(abc.ABCMeta) +class ReadOnlyScoped(credentials.ReadOnlyScoped): + """Interface for credentials whose scopes can be queried. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = _credentials_async.with_scopes(scopes=['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = _credentials_async.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + + +class Scoped(credentials.Scoped): + """Interface for credentials whose scopes can be replaced while copying. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = _credentials_async.create_scoped(['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = credentials.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + + +def with_scopes_if_required(credentials, scopes): + """Creates a copy of the credentials with scopes if scoping is required. + + This helper function is useful when you do not know (or care to know) the + specific type of credentials you are using (such as when you use + :func:`google.auth.default`). This function will call + :meth:`Scoped.with_scopes` if the credentials are scoped credentials and if + the credentials require scoping. Otherwise, it will return the credentials + as-is. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + scope if necessary. + scopes (Sequence[str]): The list of scopes to use. + + Returns: + google.auth._credentials_async.Credentials: Either a new set of scoped + credentials, or the passed in credentials instance if no scoping + was required. + """ + if isinstance(credentials, Scoped) and credentials.requires_scopes: + return credentials.with_scopes(scopes) + else: + return credentials + + +@six.add_metaclass(abc.ABCMeta) +class Signing(credentials.Signing): + """Interface for credentials that can cryptographically sign messages.""" diff --git a/google/auth/_default_async.py b/google/auth/_default_async.py new file mode 100644 index 000000000..3347fbfdc --- /dev/null +++ b/google/auth/_default_async.py @@ -0,0 +1,266 @@ +# Copyright 2020 Google Inc. +# +# 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. + +"""Application default credentials. + +Implements application default credentials and project ID detection. +""" + +import io +import json +import os + +import six + +from google.auth import _default +from google.auth import environment_vars +from google.auth import exceptions + + +def load_credentials_from_file(filename, scopes=None, quota_project_id=None): + """Loads Google credentials from a file. + + The credentials file must be a service account key or stored authorized + user credentials. + + Args: + filename (str): The full path to the credentials file. + scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary + quota_project_id (Optional[str]): The project ID used for + quota and billing. + + Returns: + Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded + credentials and the project ID. Authorized user credentials do not + have the project ID information. + + Raises: + google.auth.exceptions.DefaultCredentialsError: if the file is in the + wrong format or is missing. + """ + if not os.path.exists(filename): + raise exceptions.DefaultCredentialsError( + "File {} was not found.".format(filename) + ) + + with io.open(filename, "r") as file_obj: + try: + info = json.load(file_obj) + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "File {} is not a valid json file.".format(filename), caught_exc + ) + six.raise_from(new_exc, caught_exc) + + # The type key should indicate that the file is either a service account + # credentials file or an authorized user credentials file. + credential_type = info.get("type") + + if credential_type == _default._AUTHORIZED_USER_TYPE: + from google.oauth2 import _credentials_async as credentials + + try: + credentials = credentials.Credentials.from_authorized_user_info( + info, scopes=scopes + ).with_quota_project(quota_project_id) + except ValueError as caught_exc: + msg = "Failed to load authorized user credentials from {}".format(filename) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + six.raise_from(new_exc, caught_exc) + if not credentials.quota_project_id: + _default._warn_about_problematic_credentials(credentials) + return credentials, None + + elif credential_type == _default._SERVICE_ACCOUNT_TYPE: + from google.oauth2 import _service_account_async as service_account + + try: + credentials = service_account.Credentials.from_service_account_info( + info, scopes=scopes + ).with_quota_project(quota_project_id) + except ValueError as caught_exc: + msg = "Failed to load service account credentials from {}".format(filename) + new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) + six.raise_from(new_exc, caught_exc) + return credentials, info.get("project_id") + + else: + raise exceptions.DefaultCredentialsError( + "The file {file} does not have a valid type. " + "Type is {type}, expected one of {valid_types}.".format( + file=filename, type=credential_type, valid_types=_default._VALID_TYPES + ) + ) + + +def _get_gcloud_sdk_credentials(): + """Gets the credentials and project ID from the Cloud SDK.""" + from google.auth import _cloud_sdk + + # Check if application default credentials exist. + credentials_filename = _cloud_sdk.get_application_default_credentials_path() + + if not os.path.isfile(credentials_filename): + return None, None + + credentials, project_id = load_credentials_from_file(credentials_filename) + + if not project_id: + project_id = _cloud_sdk.get_project_id() + + return credentials, project_id + + +def _get_explicit_environ_credentials(): + """Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + variable.""" + explicit_file = os.environ.get(environment_vars.CREDENTIALS) + + if explicit_file is not None: + credentials, project_id = load_credentials_from_file( + os.environ[environment_vars.CREDENTIALS] + ) + + return credentials, project_id + + else: + return None, None + + +def _get_gae_credentials(): + """Gets Google App Engine App Identity credentials and project ID.""" + # While this library is normally bundled with app_engine, there are + # some cases where it's not available, so we tolerate ImportError. + + return _default._get_gae_credentials() + + +def _get_gce_credentials(request=None): + """Gets credentials and project ID from the GCE Metadata Service.""" + # Ping requires a transport, but we want application default credentials + # to require no arguments. So, we'll use the _http_client transport which + # uses http.client. This is only acceptable because the metadata server + # doesn't do SSL and never requires proxies. + + # While this library is normally bundled with compute_engine, there are + # some cases where it's not available, so we tolerate ImportError. + + return _default._get_gce_credentials(request) + + +def default_async(scopes=None, request=None, quota_project_id=None): + """Gets the default credentials for the current environment. + + `Application Default Credentials`_ provides an easy way to obtain + credentials to call Google APIs for server-to-server or local applications. + This function acquires credentials from the environment in the following + order: + + 1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set + to the path of a valid service account JSON private key file, then it is + loaded and returned. The project ID returned is the project ID defined + in the service account file if available (some older files do not + contain project ID information). + 2. If the `Google Cloud SDK`_ is installed and has application default + credentials set they are loaded and returned. + + To enable application default credentials with the Cloud SDK run:: + + gcloud auth application-default login + + If the Cloud SDK has an active project, the project ID is returned. The + active project can be set using:: + + gcloud config set project + + 3. If the application is running in the `App Engine standard environment`_ + then the credentials and project ID from the `App Identity Service`_ + are used. + 4. If the application is running in `Compute Engine`_ or the + `App Engine flexible environment`_ then the credentials and project ID + are obtained from the `Metadata Service`_. + 5. If no credentials are found, + :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. + + .. _Application Default Credentials: https://developers.google.com\ + /identity/protocols/application-default-credentials + .. _Google Cloud SDK: https://cloud.google.com/sdk + .. _App Engine standard environment: https://cloud.google.com/appengine + .. _App Identity Service: https://cloud.google.com/appengine/docs/python\ + /appidentity/ + .. _Compute Engine: https://cloud.google.com/compute + .. _App Engine flexible environment: https://cloud.google.com\ + /appengine/flexible + .. _Metadata Service: https://cloud.google.com/compute/docs\ + /storing-retrieving-metadata + + Example:: + + import google.auth + + credentials, project_id = google.auth.default() + + Args: + scopes (Sequence[str]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary. + request (google.auth.transport.Request): An object used to make + HTTP requests. This is used to detect whether the application + is running on Compute Engine. If not specified, then it will + use the standard library http client to make requests. + quota_project_id (Optional[str]): The project ID used for + quota and billing. + Returns: + Tuple[~google.auth.credentials.Credentials, Optional[str]]: + the current environment's credentials and project ID. Project ID + may be None, which indicates that the Project ID could not be + ascertained from the environment. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If no credentials were found, or if the credentials found were + invalid. + """ + from google.auth._credentials_async import with_scopes_if_required + + explicit_project_id = os.environ.get( + environment_vars.PROJECT, os.environ.get(environment_vars.LEGACY_PROJECT) + ) + + checkers = ( + _get_explicit_environ_credentials, + _get_gcloud_sdk_credentials, + _get_gae_credentials, + lambda: _get_gce_credentials(request), + ) + + for checker in checkers: + credentials, project_id = checker() + if credentials is not None: + credentials = with_scopes_if_required( + credentials, scopes + ).with_quota_project(quota_project_id) + effective_project_id = explicit_project_id or project_id + if not effective_project_id: + _default._LOGGER.warning( + "No project ID could be determined. Consider running " + "`gcloud config set project` or setting the %s " + "environment variable", + environment_vars.PROJECT, + ) + return credentials, effective_project_id + + raise exceptions.DefaultCredentialsError(_default._HELP_MESSAGE) diff --git a/google/auth/_jwt_async.py b/google/auth/_jwt_async.py new file mode 100644 index 000000000..49e3026e5 --- /dev/null +++ b/google/auth/_jwt_async.py @@ -0,0 +1,168 @@ +# Copyright 2020 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. + +"""JSON Web Tokens + +Provides support for creating (encoding) and verifying (decoding) JWTs, +especially JWTs generated and consumed by Google infrastructure. + +See `rfc7519`_ for more details on JWTs. + +To encode a JWT use :func:`encode`:: + + from google.auth import crypt + from google.auth import jwt_async + + signer = crypt.Signer(private_key) + payload = {'some': 'payload'} + encoded = jwt_async.encode(signer, payload) + +To decode a JWT and verify claims use :func:`decode`:: + + claims = jwt_async.decode(encoded, certs=public_certs) + +You can also skip verification:: + + claims = jwt_async.decode(encoded, verify=False) + +.. _rfc7519: https://tools.ietf.org/html/rfc7519 + + +NOTE: This async support is experimental and marked internal. This surface may +change in minor releases. +""" + +import google.auth +from google.auth import jwt + + +def encode(signer, payload, header=None, key_id=None): + """Make a signed JWT. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign the JWT. + payload (Mapping[str, str]): The JWT payload. + header (Mapping[str, str]): Additional JWT header payload. + key_id (str): The key id to add to the JWT header. If the + signer has a key id it will be used as the default. If this is + specified it will override the signer's key id. + + Returns: + bytes: The encoded JWT. + """ + return jwt.encode(signer, payload, header, key_id) + + +def decode(token, certs=None, verify=True, audience=None): + """Decode and verify a JWT. + + Args: + token (str): The encoded JWT. + certs (Union[str, bytes, Mapping[str, Union[str, bytes]]]): The + certificate used to validate the JWT signature. If bytes or string, + it must the the public key certificate in PEM format. If a mapping, + it must be a mapping of key IDs to public key certificates in PEM + format. The mapping must contain the same key ID that's specified + in the token's header. + verify (bool): Whether to perform signature and claim validation. + Verification is done by default. + audience (str): The audience claim, 'aud', that this JWT should + contain. If None then the JWT's 'aud' parameter is not verified. + + Returns: + Mapping[str, str]: The deserialized JSON payload in the JWT. + + Raises: + ValueError: if any verification checks failed. + """ + + return jwt.decode(token, certs, verify, audience) + + +class Credentials( + jwt.Credentials, + google.auth._credentials_async.Signing, + google.auth._credentials_async.Credentials, +): + """Credentials that use a JWT as the bearer token. + + These credentials require an "audience" claim. This claim identifies the + intended recipient of the bearer token. + + The constructor arguments determine the claims for the JWT that is + sent with requests. Usually, you'll construct these credentials with + one of the helper constructors as shown in the next section. + + To create JWT credentials using a Google service account private key + JSON file:: + + audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher' + credentials = jwt_async.Credentials.from_service_account_file( + 'service-account.json', + audience=audience) + + If you already have the service account file loaded and parsed:: + + service_account_info = json.load(open('service_account.json')) + credentials = jwt_async.Credentials.from_service_account_info( + service_account_info, + audience=audience) + + Both helper methods pass on arguments to the constructor, so you can + specify the JWT claims:: + + credentials = jwt_async.Credentials.from_service_account_file( + 'service-account.json', + audience=audience, + additional_claims={'meta': 'data'}) + + You can also construct the credentials directly if you have a + :class:`~google.auth.crypt.Signer` instance:: + + credentials = jwt_async.Credentials( + signer, + issuer='your-issuer', + subject='your-subject', + audience=audience) + + The claims are considered immutable. If you want to modify the claims, + you can easily create another instance using :meth:`with_claims`:: + + new_audience = ( + 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber') + new_credentials = credentials.with_claims(audience=new_audience) + """ + + +class OnDemandCredentials( + jwt.OnDemandCredentials, + google.auth._credentials_async.Signing, + google.auth._credentials_async.Credentials, +): + """On-demand JWT credentials. + + Like :class:`Credentials`, this class uses a JWT as the bearer token for + authentication. However, this class does not require the audience at + construction time. Instead, it will generate a new token on-demand for + each request using the request URI as the audience. It caches tokens + so that multiple requests to the same URI do not incur the overhead + of generating a new token every time. + + This behavior is especially useful for `gRPC`_ clients. A gRPC service may + have multiple audience and gRPC clients may not know all of the audiences + required for accessing a particular service. With these credentials, + no knowledge of the audiences is required ahead of time. + + .. _grpc: http://www.grpc.io/ + """ diff --git a/google/auth/transport/_aiohttp_requests.py b/google/auth/transport/_aiohttp_requests.py new file mode 100644 index 000000000..aaf4e2c0b --- /dev/null +++ b/google/auth/transport/_aiohttp_requests.py @@ -0,0 +1,384 @@ +# Copyright 2020 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 Async HTTP (aiohttp). + +NOTE: This async support is experimental and marked internal. This surface may +change in minor releases. +""" + +from __future__ import absolute_import + +import asyncio +import functools + +import aiohttp +import six +import urllib3 + +from google.auth import exceptions +from google.auth import transport +from google.auth.transport import requests + +# Timeout can be re-defined depending on async requirement. Currently made 60s more than +# sync timeout. +_DEFAULT_TIMEOUT = 180 # in seconds + + +class _CombinedResponse(transport.Response): + """ + In order to more closely resemble the `requests` interface, where a raw + and deflated content could be accessed at once, this class lazily reads the + stream in `transport.Response` so both return forms can be used. + + The gzip and deflate transfer-encodings are automatically decoded for you + because the default parameter for autodecompress into the ClientSession is set + to False, and therefore we add this class to act as a wrapper for a user to be + able to access both the raw and decoded response bodies - mirroring the sync + implementation. + """ + + def __init__(self, response): + self._response = response + self._raw_content = None + + def _is_compressed(self): + headers = self._response.headers + return "Content-Encoding" in headers and ( + headers["Content-Encoding"] == "gzip" + or headers["Content-Encoding"] == "deflate" + ) + + @property + def status(self): + return self._response.status + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.content + + async def raw_content(self): + if self._raw_content is None: + self._raw_content = await self._response.content.read() + return self._raw_content + + async def content(self): + # Load raw_content if necessary + await self.raw_content() + if self._is_compressed(): + decoder = urllib3.response.MultiDecoder( + self._response.headers["Content-Encoding"] + ) + decompressed = decoder.decompress(self._raw_content) + return decompressed + + return self._raw_content + + +class _Response(transport.Response): + """ + Requests transport response adapter. + + Args: + response (requests.Response): The raw Requests response. + """ + + def __init__(self, response): + self._response = response + + @property + def status(self): + return self._response.status + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.content + + +class Request(transport.Request): + """Requests request adapter. + + This class is used internally for making requests using asyncio transports + in a consistent way. If you use :class:`AuthorizedSession` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.credentials.Credentials` instance:: + + import google.auth.transport.aiohttp_requests + + request = google.auth.transport.aiohttp_requests.Request() + + credentials.refresh(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=None): + self.session = None + + async def __call__( + self, + url, + method="GET", + body=None, + headers=None, + timeout=_DEFAULT_TIMEOUT, + **kwargs, + ): + """ + Make an HTTP request using aiohttp. + + Args: + url (str): The URL to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): 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 + requests :meth:`~requests.Session.request` method. + + Returns: + google.auth.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + + try: + if self.session is None: # pragma: NO COVER + self.session = aiohttp.ClientSession( + auto_decompress=False + ) # pragma: NO COVER + requests._LOGGER.debug("Making request: %s %s", method, url) + response = await self.session.request( + method, url, data=body, headers=headers, timeout=timeout, **kwargs + ) + return _CombinedResponse(response) + + except aiohttp.ClientError as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + six.raise_from(new_exc, caught_exc) + + except asyncio.TimeoutError as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + six.raise_from(new_exc, caught_exc) + + +class AuthorizedSession(aiohttp.ClientSession): + """This is an async implementation of the Authorized Session class. We utilize an + aiohttp transport instance, and the interface mirrors the google.auth.transport.requests + Authorized Session class, except for the change in the transport used in the async use case. + + A Requests Session class with credentials. + + This class is used to perform requests to API endpoints that require + authorization:: + + from google.auth.transport import aiohttp_requests + + async with aiohttp_requests.AuthorizedSession(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._credentials_async.Credentials): The credentials to + add to the request. + refresh_status_codes (Sequence[int]): Which HTTP status codes indicate + that credentials should be refreshed and the request should be + retried. + max_refresh_attempts (int): The maximum number of times to attempt to + refresh the credentials and retry the request. + refresh_timeout (Optional[int]): The timeout value in seconds for + credential refresh HTTP requests. + auth_request (google.auth.transport.aiohttp_requests.Request): + (Optional) An instance of + :class:`~google.auth.transport.aiohttp_requests.Request` used when + refreshing credentials. If not passed, + an instance of :class:`~google.auth.transport.aiohttp_requests.Request` + is created. + """ + + def __init__( + self, + credentials, + refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, + max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS, + refresh_timeout=None, + auth_request=None, + auto_decompress=False, + ): + super(AuthorizedSession, self).__init__() + self.credentials = credentials + self._refresh_status_codes = refresh_status_codes + self._max_refresh_attempts = max_refresh_attempts + self._refresh_timeout = refresh_timeout + self._is_mtls = False + self._auth_request = auth_request + self._auth_request_session = None + self._loop = asyncio.get_event_loop() + self._refresh_lock = asyncio.Lock() + self._auto_decompress = auto_decompress + + async def request( + self, + method, + url, + data=None, + headers=None, + max_allowed_time=None, + timeout=_DEFAULT_TIMEOUT, + auto_decompress=False, + **kwargs, + ): + + """Implementation of Authorized Session aiohttp request. + + Args: + method: The http request method used (e.g. GET, PUT, DELETE) + + url: The url at which the http request is sent. + + data, headers: These fields parallel the associated data and headers + fields of a regular http request. Using the aiohttp client session to + send the http request allows us to use this parallel corresponding structure + in our Authorized Session class. + + timeout (Optional[Union[float, aiohttp.ClientTimeout]]): + The amount of time in seconds to wait for the server response + with each individual request. + + Can also be passed as an `aiohttp.ClientTimeout` object. + + max_allowed_time (Optional[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. + """ + # Headers come in as bytes which isn't expected behavior, the resumable + # media libraries in some cases expect a str type for the header values, + # but sometimes the operations return these in bytes types. + if headers: + for key in headers.keys(): + if type(headers[key]) is bytes: + headers[key] = headers[key].decode("utf-8") + + async with aiohttp.ClientSession( + auto_decompress=self._auto_decompress + ) as self._auth_request_session: + auth_request = Request(self._auth_request_session) + self._auth_request = auth_request + + # Use a kwarg for this instead of an attribute to maintain + # thread-safety. + _credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0) + # Make a copy of the headers. They will be modified by the credentials + # and we want to pass the original headers if we recurse. + request_headers = headers.copy() if headers is not None else {} + + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) + ) + + remaining_time = max_allowed_time + + with requests.TimeoutGuard(remaining_time, asyncio.TimeoutError) as guard: + await self.credentials.before_request( + auth_request, method, url, request_headers + ) + + with requests.TimeoutGuard(remaining_time, asyncio.TimeoutError) as guard: + response = await super(AuthorizedSession, self).request( + method, + url, + data=data, + headers=request_headers, + timeout=timeout, + **kwargs, + ) + + remaining_time = guard.remaining_timeout + + if ( + response.status in self._refresh_status_codes + and _credential_refresh_attempt < self._max_refresh_attempts + ): + + requests._LOGGER.info( + "Refreshing credentials due to a %s response. Attempt %s/%s.", + response.status, + _credential_refresh_attempt + 1, + self._max_refresh_attempts, + ) + + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) + ) + + with requests.TimeoutGuard( + remaining_time, asyncio.TimeoutError + ) as guard: + async with self._refresh_lock: + await self._loop.run_in_executor( + None, self.credentials.refresh, auth_request + ) + + remaining_time = guard.remaining_timeout + + return await self.request( + method, + url, + data=data, + headers=headers, + max_allowed_time=remaining_time, + timeout=timeout, + _credential_refresh_attempt=_credential_refresh_attempt + 1, + **kwargs, + ) + + return response diff --git a/google/auth/transport/mtls.py b/google/auth/transport/mtls.py index 5b742306b..b40bfbedf 100644 --- a/google/auth/transport/mtls.py +++ b/google/auth/transport/mtls.py @@ -86,9 +86,12 @@ def default_client_encrypted_cert_source(cert_path, key_path): def callback(): try: - _, cert_bytes, key_bytes, passphrase_bytes = _mtls_helper.get_client_ssl_credentials( - generate_encrypted_key=True - ) + ( + _, + cert_bytes, + key_bytes, + passphrase_bytes, + ) = _mtls_helper.get_client_ssl_credentials(generate_encrypted_key=True) with open(cert_path, "wb") as cert_file: cert_file.write(cert_bytes) with open(key_path, "wb") as key_file: diff --git a/google/oauth2/_client_async.py b/google/oauth2/_client_async.py new file mode 100644 index 000000000..4817ea40e --- /dev/null +++ b/google/oauth2/_client_async.py @@ -0,0 +1,264 @@ +# Copyright 2020 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. + +"""OAuth 2.0 async client. + +This is a client for interacting with an OAuth 2.0 authorization server's +token endpoint. + +For more information about the token endpoint, see +`Section 3.1 of rfc6749`_ + +.. _Section 3.1 of rfc6749: https://tools.ietf.org/html/rfc6749#section-3.2 +""" + +import datetime +import json + +import six +from six.moves import http_client +from six.moves import urllib + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import jwt +from google.oauth2 import _client as client + + +def _handle_error_response(response_body): + """"Translates an error response into an exception. + + Args: + response_body (str): The decoded response data. + + Raises: + google.auth.exceptions.RefreshError + """ + try: + error_data = json.loads(response_body) + error_details = "{}: {}".format( + error_data["error"], error_data.get("error_description") + ) + # If no details could be extracted, use the response data. + except (KeyError, ValueError): + error_details = response_body + + raise exceptions.RefreshError(error_details, response_body) + + +def _parse_expiry(response_data): + """Parses the expiry field from a response into a datetime. + + Args: + response_data (Mapping): The JSON-parsed response data. + + Returns: + Optional[datetime]: The expiration or ``None`` if no expiration was + specified. + """ + expires_in = response_data.get("expires_in", None) + + if expires_in is not None: + return _helpers.utcnow() + datetime.timedelta(seconds=expires_in) + else: + return None + + +async def _token_endpoint_request(request, token_uri, body): + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + body (Mapping[str, str]): The parameters to send in the request body. + + Returns: + Mapping[str, str]: The JSON-decoded response data. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = urllib.parse.urlencode(body).encode("utf-8") + headers = {"content-type": client._URLENCODED_CONTENT_TYPE} + + retry = 0 + # retry to fetch token for maximum of two times if any internal failure + # occurs. + while True: + + response = await request( + method="POST", url=token_uri, headers=headers, body=body + ) + + # Using data.read() resulted in zlib decompression errors. This may require future investigation. + response_body1 = await response.content() + + response_body = ( + response_body1.decode("utf-8") + if hasattr(response_body1, "decode") + else response_body1 + ) + + response_data = json.loads(response_body) + + if response.status == http_client.OK: + break + else: + error_desc = response_data.get("error_description") or "" + error_code = response_data.get("error") or "" + if ( + any(e == "internal_failure" for e in (error_code, error_desc)) + and retry < 1 + ): + retry += 1 + continue + _handle_error_response(response_body) + + return response_data + + +async def jwt_grant(request, token_uri, assertion): + """Implements the JWT Profile for OAuth 2.0 Authorization Grants. + + For more details, see `rfc7523 section 4`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + assertion (str): The OAuth 2.0 assertion. + + Returns: + Tuple[str, Optional[datetime], Mapping[str, str]]: The access token, + expiration, and additional data returned by the token endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + + .. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4 + """ + body = {"assertion": assertion, "grant_type": client._JWT_GRANT_TYPE} + + response_data = await _token_endpoint_request(request, token_uri, body) + + try: + access_token = response_data["access_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError("No access token in response.", response_data) + six.raise_from(new_exc, caught_exc) + + expiry = _parse_expiry(response_data) + + return access_token, expiry, response_data + + +async def id_token_jwt_grant(request, token_uri, assertion): + """Implements the JWT Profile for OAuth 2.0 Authorization Grants, but + requests an OpenID Connect ID Token instead of an access token. + + This is a variant on the standard JWT Profile that is currently unique + to Google. This was added for the benefit of authenticating to services + that require ID Tokens instead of access tokens or JWT bearer tokens. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorization server's token endpoint + URI. + assertion (str): JWT token signed by a service account. The token's + payload must include a ``target_audience`` claim. + + Returns: + Tuple[str, Optional[datetime], Mapping[str, str]]: + The (encoded) Open ID Connect ID Token, expiration, and additional + data returned by the endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = {"assertion": assertion, "grant_type": client._JWT_GRANT_TYPE} + + response_data = await _token_endpoint_request(request, token_uri, body) + + try: + id_token = response_data["id_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError("No ID token in response.", response_data) + six.raise_from(new_exc, caught_exc) + + payload = jwt.decode(id_token, verify=False) + expiry = datetime.datetime.utcfromtimestamp(payload["exp"]) + + return id_token, expiry, response_data + + +async def refresh_grant( + request, token_uri, refresh_token, client_id, client_secret, scopes=None +): + """Implements the OAuth 2.0 refresh token grant. + + For more details, see `rfc678 section 6`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + refresh_token (str): The refresh token to use to get a new access + token. + client_id (str): The OAuth 2.0 application's client ID. + client_secret (str): The Oauth 2.0 appliaction's client secret. + scopes (Optional(Sequence[str])): Scopes to request. If present, all + scopes must be authorized for the refresh token. Useful if refresh + token has a wild card scope (e.g. + 'https://www.googleapis.com/auth/any-api'). + + Returns: + Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: The + access token, new refresh token, expiration, and additional data + returned by the token endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + + .. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6 + """ + body = { + "grant_type": client._REFRESH_GRANT_TYPE, + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + if scopes: + body["scope"] = " ".join(scopes) + + response_data = await _token_endpoint_request(request, token_uri, body) + + try: + access_token = response_data["access_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError("No access token in response.", response_data) + six.raise_from(new_exc, caught_exc) + + refresh_token = response_data.get("refresh_token", refresh_token) + expiry = _parse_expiry(response_data) + + return access_token, refresh_token, expiry, response_data diff --git a/google/oauth2/_credentials_async.py b/google/oauth2/_credentials_async.py new file mode 100644 index 000000000..eb3e97c08 --- /dev/null +++ b/google/oauth2/_credentials_async.py @@ -0,0 +1,108 @@ +# Copyright 2020 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. + +"""OAuth 2.0 Async Credentials. + +This module provides credentials based on OAuth 2.0 access and refresh tokens. +These credentials usually access resources on behalf of a user (resource +owner). + +Specifically, this is intended to use access tokens acquired using the +`Authorization Code grant`_ and can refresh those tokens using a +optional `refresh token`_. + +Obtaining the initial access and refresh token is outside of the scope of this +module. Consult `rfc6749 section 4.1`_ for complete details on the +Authorization Code grant flow. + +.. _Authorization Code grant: https://tools.ietf.org/html/rfc6749#section-1.3.1 +.. _refresh token: https://tools.ietf.org/html/rfc6749#section-6 +.. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1 +""" + +from google.auth import _credentials_async as credentials +from google.auth import _helpers +from google.auth import exceptions +from google.oauth2 import _client_async as _client +from google.oauth2 import credentials as oauth2_credentials + + +class Credentials(oauth2_credentials.Credentials): + """Credentials using OAuth 2.0 access and refresh tokens. + + The credentials are considered immutable. If you want to modify the + quota project, use :meth:`with_quota_project` or :: + + credentials = credentials.with_quota_project('myproject-123) + """ + + @_helpers.copy_docstring(credentials.Credentials) + async def refresh(self, request): + if ( + self._refresh_token is None + or self._token_uri is None + or self._client_id is None + or self._client_secret is None + ): + raise exceptions.RefreshError( + "The credentials do not contain the necessary fields need to " + "refresh the access token. You must specify refresh_token, " + "token_uri, client_id, and client_secret." + ) + + ( + access_token, + refresh_token, + expiry, + grant_response, + ) = await _client.refresh_grant( + request, + self._token_uri, + self._refresh_token, + self._client_id, + self._client_secret, + self._scopes, + ) + + self.token = access_token + self.expiry = expiry + self._refresh_token = refresh_token + self._id_token = grant_response.get("id_token") + + if self._scopes and "scopes" in grant_response: + requested_scopes = frozenset(self._scopes) + granted_scopes = frozenset(grant_response["scopes"].split()) + scopes_requested_but_not_granted = requested_scopes - granted_scopes + if scopes_requested_but_not_granted: + raise exceptions.RefreshError( + "Not all requested scopes were granted by the " + "authorization server, missing scopes {}.".format( + ", ".join(scopes_requested_but_not_granted) + ) + ) + + +class UserAccessTokenCredentials(oauth2_credentials.UserAccessTokenCredentials): + """Access token credentials for user account. + + Obtain the access token for a given user account or the current active + user account with the ``gcloud auth print-access-token`` command. + + Args: + account (Optional[str]): Account to get the access token for. If not + specified, the current active account will be used. + quota_project_id (Optional[str]): The project ID used for quota + and billing. + + """ diff --git a/google/oauth2/_id_token_async.py b/google/oauth2/_id_token_async.py new file mode 100644 index 000000000..f5ef8baff --- /dev/null +++ b/google/oauth2/_id_token_async.py @@ -0,0 +1,267 @@ +# Copyright 2020 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. + +"""Google ID Token helpers. + +Provides support for verifying `OpenID Connect ID Tokens`_, especially ones +generated by Google infrastructure. + +To parse and verify an ID Token issued by Google's OAuth 2.0 authorization +server use :func:`verify_oauth2_token`. To verify an ID Token issued by +Firebase, use :func:`verify_firebase_token`. + +A general purpose ID Token verifier is available as :func:`verify_token`. + +Example:: + + from google.oauth2 import _id_token_async + from google.auth.transport import aiohttp_requests + + request = aiohttp_requests.Request() + + id_info = await _id_token_async.verify_oauth2_token( + token, request, 'my-client-id.example.com') + + if id_info['iss'] != 'https://accounts.google.com': + raise ValueError('Wrong issuer.') + + userid = id_info['sub'] + +By default, this will re-fetch certificates for each verification. Because +Google's public keys are only changed infrequently (on the order of once per +day), you may wish to take advantage of caching to reduce latency and the +potential for network errors. This can be accomplished using an external +library like `CacheControl`_ to create a cache-aware +:class:`google.auth.transport.Request`:: + + import cachecontrol + import google.auth.transport.requests + import requests + + session = requests.session() + cached_session = cachecontrol.CacheControl(session) + request = google.auth.transport.requests.Request(session=cached_session) + +.. _OpenID Connect ID Token: + http://openid.net/specs/openid-connect-core-1_0.html#IDToken +.. _CacheControl: https://cachecontrol.readthedocs.io +""" + +import json +import os + +import six +from six.moves import http_client + +from google.auth import environment_vars +from google.auth import exceptions +from google.auth import jwt +from google.auth.transport import requests +from google.oauth2 import id_token as sync_id_token + + +async def _fetch_certs(request, certs_url): + """Fetches certificates. + + Google-style cerificate endpoints return JSON in the format of + ``{'key id': 'x509 certificate'}``. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + certs_url (str): The certificate endpoint URL. + + Returns: + Mapping[str, str]: A mapping of public key ID to x.509 certificate + data. + """ + response = await request(certs_url, method="GET") + + if response.status != http_client.OK: + raise exceptions.TransportError( + "Could not fetch certificates at {}".format(certs_url) + ) + + data = await response.data.read() + + return json.loads(json.dumps(data)) + + +async def verify_token( + id_token, request, audience=None, certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL +): + """Verifies an ID token and returns the decoded token. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + audience (str): The audience that this token is intended for. If None + then the audience is not verified. + certs_url (str): The URL that specifies the certificates to use to + verify the token. This URL should return JSON in the format of + ``{'key id': 'x509 certificate'}``. + + Returns: + Mapping[str, Any]: The decoded token. + """ + certs = await _fetch_certs(request, certs_url) + + return jwt.decode(id_token, certs=certs, audience=audience) + + +async def verify_oauth2_token(id_token, request, audience=None): + """Verifies an ID Token issued by Google's OAuth 2.0 authorization server. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + audience (str): The audience that this token is intended for. This is + typically your application's OAuth 2.0 client ID. If None then the + audience is not verified. + + Returns: + Mapping[str, Any]: The decoded token. + + Raises: + exceptions.GoogleAuthError: If the issuer is invalid. + """ + idinfo = await verify_token( + id_token, + request, + audience=audience, + certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL, + ) + + if idinfo["iss"] not in sync_id_token._GOOGLE_ISSUERS: + raise exceptions.GoogleAuthError( + "Wrong issuer. 'iss' should be one of the following: {}".format( + sync_id_token._GOOGLE_ISSUERS + ) + ) + + return idinfo + + +async def verify_firebase_token(id_token, request, audience=None): + """Verifies an ID Token issued by Firebase Authentication. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. This must be an aiohttp request. + audience (str): The audience that this token is intended for. This is + typically your Firebase application ID. If None then the audience + is not verified. + + Returns: + Mapping[str, Any]: The decoded token. + """ + return await verify_token( + id_token, + request, + audience=audience, + certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL, + ) + + +async def fetch_id_token(request, audience): + """Fetch the ID Token from the current environment. + + This function acquires ID token from the environment in the following order: + + 1. If the application is running in Compute Engine, App Engine or Cloud Run, + then the ID token are obtained from the metadata server. + 2. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set + to the path of a valid service account JSON file, then ID token is + acquired using this service account credentials. + 3. If metadata server doesn't exist and no valid service account credentials + are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will + be raised. + + Example:: + + import google.oauth2._id_token_async + import google.auth.transport.aiohttp_requests + + request = google.auth.transport.aiohttp_requests.Request() + target_audience = "https://pubsub.googleapis.com" + + id_token = await google.oauth2._id_token_async.fetch_id_token(request, target_audience) + + Args: + request (google.auth.transport.aiohttp_requests.Request): A callable used to make + HTTP requests. + audience (str): The audience that this ID token is intended for. + + Returns: + str: The ID token. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If metadata server doesn't exist and no valid service account + credentials are found. + """ + # 1. First try to fetch ID token from metadata server if it exists. The code + # works for GAE and Cloud Run metadata server as well. + try: + from google.auth import compute_engine + + request_new = requests.Request() + credentials = compute_engine.IDTokenCredentials( + request_new, audience, use_metadata_identity_endpoint=True + ) + credentials.refresh(request_new) + + return credentials.token + + except (ImportError, exceptions.TransportError, exceptions.RefreshError): + pass + + # 2. Try to use service account credentials to get ID token. + + # Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + # variable. + credentials_filename = os.environ.get(environment_vars.CREDENTIALS) + if not ( + credentials_filename + and os.path.exists(credentials_filename) + and os.path.isfile(credentials_filename) + ): + raise exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found." + ) + + try: + with open(credentials_filename, "r") as f: + info = json.load(f) + credentials_content = ( + (info.get("type") == "service_account") and info or None + ) + + from google.oauth2 import _service_account_async as service_account + + credentials = service_account.IDTokenCredentials.from_service_account_info( + credentials_content, target_audience=audience + ) + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found.", + caught_exc, + ) + six.raise_from(new_exc, caught_exc) + + await credentials.refresh(request) + return credentials.token diff --git a/google/oauth2/_service_account_async.py b/google/oauth2/_service_account_async.py new file mode 100644 index 000000000..0a4e724a4 --- /dev/null +++ b/google/oauth2/_service_account_async.py @@ -0,0 +1,132 @@ +# Copyright 2020 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. + +"""Service Accounts: JSON Web Token (JWT) Profile for OAuth 2.0 + +NOTE: This file adds asynchronous refresh methods to both credentials +classes, and therefore async/await syntax is required when calling this +method when using service account credentials with asynchronous functionality. +Otherwise, all other methods are inherited from the regular service account +credentials file google.oauth2.service_account + +""" + +from google.auth import _credentials_async as credentials_async +from google.auth import _helpers +from google.oauth2 import _client_async +from google.oauth2 import service_account + + +class Credentials( + service_account.Credentials, credentials_async.Scoped, credentials_async.Credentials +): + """Service account credentials + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = _service_account_async.Credentials.from_service_account_file( + 'service-account.json') + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = _service_account_async.Credentials.from_service_account_info( + service_account_info) + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = _service_account_async.Credentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com') + + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + + To add a quota project, use :meth:`with_quota_project`:: + + credentials = credentials.with_quota_project('myproject-123') + """ + + @_helpers.copy_docstring(credentials_async.Credentials) + async def refresh(self, request): + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = await _client_async.jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry + + +class IDTokenCredentials( + service_account.IDTokenCredentials, + credentials_async.Signing, + credentials_async.Credentials, +): + """Open ID Connect ID Token-based service account credentials. + + These credentials are largely similar to :class:`.Credentials`, but instead + of using an OAuth 2.0 Access Token as the bearer token, they use an Open + ID Connect ID Token as the bearer token. These credentials are useful when + communicating to services that require ID Tokens and can not accept access + tokens. + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = ( + _service_account_async.IDTokenCredentials.from_service_account_file( + 'service-account.json')) + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = ( + _service_account_async.IDTokenCredentials.from_service_account_info( + service_account_info)) + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = ( + _service_account_async.IDTokenCredentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com')) +` + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + + """ + + @_helpers.copy_docstring(credentials_async.Credentials) + async def refresh(self, request): + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = await _client_async.id_token_jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry diff --git a/noxfile.py b/noxfile.py index c39f27c47..d497f5305 100644 --- a/noxfile.py +++ b/noxfile.py @@ -29,8 +29,18 @@ "responses", "grpcio", ] + +ASYNC_DEPENDENCIES = ["pytest-asyncio", "aioresponses"] + BLACK_VERSION = "black==19.3b0" -BLACK_PATHS = ["google", "tests", "noxfile.py", "setup.py", "docs/conf.py"] +BLACK_PATHS = [ + "google", + "tests", + "tests_async", + "noxfile.py", + "setup.py", + "docs/conf.py", +] @nox.session(python="3.7") @@ -44,6 +54,7 @@ def lint(session): "--application-import-names=google,tests,system_tests", "google", "tests", + "tests_async", ) session.run( "python", "setup.py", "check", "--metadata", "--restructuredtext", "--strict" @@ -64,8 +75,23 @@ def blacken(session): session.run("black", *BLACK_PATHS) -@nox.session(python=["2.7", "3.5", "3.6", "3.7", "3.8"]) +@nox.session(python=["3.6", "3.7", "3.8"]) def unit(session): + session.install(*TEST_DEPENDENCIES) + session.install(*(ASYNC_DEPENDENCIES)) + session.install(".") + session.run( + "pytest", + "--cov=google.auth", + "--cov=google.oauth2", + "--cov=tests", + "tests", + "tests_async", + ) + + +@nox.session(python=["2.7", "3.5"]) +def unit_prev_versions(session): session.install(*TEST_DEPENDENCIES) session.install(".") session.run( @@ -76,14 +102,17 @@ def unit(session): @nox.session(python="3.7") def cover(session): session.install(*TEST_DEPENDENCIES) + session.install(*(ASYNC_DEPENDENCIES)) session.install(".") session.run( "pytest", "--cov=google.auth", "--cov=google.oauth2", "--cov=tests", + "--cov=tests_async", "--cov-report=", "tests", + "tests_async", ) session.run("coverage", "report", "--show-missing", "--fail-under=100") @@ -117,5 +146,10 @@ def pypy(session): session.install(*TEST_DEPENDENCIES) session.install(".") session.run( - "pytest", "--cov=google.auth", "--cov=google.oauth2", "--cov=tests", "tests" + "pytest", + "--cov=google.auth", + "--cov=google.oauth2", + "--cov=tests", + "tests", + "tests_async", ) diff --git a/setup.py b/setup.py index 1c3578f60..dd58f30f2 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ 'rsa>=3.1.4,<5; python_version >= "3.5"', "setuptools>=40.3.0", "six>=1.9.0", + 'aiohttp >= 3.6.2, < 4.0.0dev; python_version>="3.6"', ) diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index 14cd3db8e..a039228d9 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -1,4 +1,4 @@ -# Copyright 2016 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -29,7 +29,6 @@ import nox import py.path - HERE = os.path.abspath(os.path.dirname(__file__)) LIBRARY_DIR = os.path.join(HERE, "..") DATA_DIR = os.path.join(HERE, "data") @@ -169,92 +168,79 @@ def configure_cloud_sdk(session, application_default_credentials, project=False) # Test sesssions -TEST_DEPENDENCIES = ["pytest", "requests"] -PYTHON_VERSIONS = ["2.7", "3.7"] - - -@nox.session(python=PYTHON_VERSIONS) -def service_account(session): - session.install(*TEST_DEPENDENCIES) - session.install(LIBRARY_DIR) - session.run("pytest", "test_service_account.py") - - -@nox.session(python=PYTHON_VERSIONS) -def oauth2_credentials(session): - session.install(*TEST_DEPENDENCIES) - session.install(LIBRARY_DIR) - session.run("pytest", "test_oauth2_credentials.py") +TEST_DEPENDENCIES_ASYNC = ["aiohttp", "pytest-asyncio", "nest-asyncio"] +TEST_DEPENDENCIES_SYNC = ["pytest", "requests"] +PYTHON_VERSIONS_ASYNC = ["3.7"] +PYTHON_VERSIONS_SYNC = ["2.7", "3.7"] -@nox.session(python=PYTHON_VERSIONS) -def impersonated_credentials(session): - session.install(*TEST_DEPENDENCIES) +@nox.session(python=PYTHON_VERSIONS_SYNC) +def service_account_sync(session): + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) - session.run("pytest", "test_impersonated_credentials.py") + session.run("pytest", "system_tests_sync/test_service_account.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def default_explicit_service_account(session): session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE session.env[EXPECT_PROJECT_ENV] = "1" - session.install(*TEST_DEPENDENCIES) + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py", "test_id_token.py") + session.run("pytest", "system_tests_sync/test_default.py", "system_tests_sync/test_id_token.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def default_explicit_authorized_user(session): session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE - session.install(*TEST_DEPENDENCIES) + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py") + session.run("pytest", "system_tests_sync/test_default.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def default_explicit_authorized_user_explicit_project(session): session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE session.env[EXPLICIT_PROJECT_ENV] = "example-project" session.env[EXPECT_PROJECT_ENV] = "1" - session.install(*TEST_DEPENDENCIES) + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py") + session.run("pytest", "system_tests_sync/test_default.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def default_cloud_sdk_service_account(session): configure_cloud_sdk(session, SERVICE_ACCOUNT_FILE) session.env[EXPECT_PROJECT_ENV] = "1" - session.install(*TEST_DEPENDENCIES) + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py") + session.run("pytest", "system_tests_sync/test_default.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def default_cloud_sdk_authorized_user(session): configure_cloud_sdk(session, AUTHORIZED_USER_FILE) - session.install(*TEST_DEPENDENCIES) + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py") + session.run("pytest", "system_tests_sync/test_default.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def default_cloud_sdk_authorized_user_configured_project(session): configure_cloud_sdk(session, AUTHORIZED_USER_FILE, project=True) session.env[EXPECT_PROJECT_ENV] = "1" - session.install(*TEST_DEPENDENCIES) + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py") - + session.run("pytest", "system_tests_sync/test_default.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def compute_engine(session): - session.install(*TEST_DEPENDENCIES) + session.install(*TEST_DEPENDENCIES_SYNC) # unset Application Default Credentials so # credentials are detected from environment del session.virtualenv.env["GOOGLE_APPLICATION_CREDENTIALS"] session.install(LIBRARY_DIR) - session.run("pytest", "test_compute_engine.py") + session.run("pytest", "system_tests_sync/test_compute_engine.py") @nox.session(python=["2.7"]) @@ -283,8 +269,8 @@ def app_engine(session): application_url = GAE_APP_URL_TMPL.format(GAE_TEST_APP_SERVICE, project_id) # Vendor in the test application's dependencies - session.chdir(os.path.join(HERE, "app_engine_test_app")) - session.install(*TEST_DEPENDENCIES) + session.chdir(os.path.join(HERE, "../app_engine_test_app")) + session.install(*TEST_DEPENDENCIES_SYNC) session.install(LIBRARY_DIR) session.run( "pip", "install", "--target", "lib", "-r", "requirements.txt", silent=True @@ -296,20 +282,82 @@ def app_engine(session): # Run the tests session.env["TEST_APP_URL"] = application_url session.chdir(HERE) - session.run("pytest", "test_app_engine.py") + session.run("pytest", "system_tests_sync/test_app_engine.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def grpc(session): session.install(LIBRARY_DIR) - session.install(*TEST_DEPENDENCIES, "google-cloud-pubsub==1.0.0") + session.install(*TEST_DEPENDENCIES_SYNC, "google-cloud-pubsub==1.0.0") session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE - session.run("pytest", "test_grpc.py") + session.run("pytest", "system_tests_sync/test_grpc.py") -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS_SYNC) def mtls_http(session): session.install(LIBRARY_DIR) - session.install(*TEST_DEPENDENCIES, "pyopenssl") + session.install(*TEST_DEPENDENCIES_SYNC, "pyopenssl") + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + session.run("pytest", "system_tests_sync/test_mtls_http.py") + +#ASYNC SYSTEM TESTS + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def service_account_async(session): + session.install(*(TEST_DEPENDENCIES_SYNC+TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + session.run("pytest", "system_tests_async/test_service_account.py") + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_explicit_service_account_async(session): session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE - session.run("pytest", "test_mtls_http.py") + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + session.run("pytest", "system_tests_async/test_default.py", + "system_tests_async/test_id_token.py") + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_explicit_authorized_user_async(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + session.run("pytest", "system_tests_async/test_default.py") + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_explicit_authorized_user_explicit_project_async(session): + session.env[EXPLICIT_CREDENTIALS_ENV] = AUTHORIZED_USER_FILE + session.env[EXPLICIT_PROJECT_ENV] = "example-project" + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + session.run("pytest", "system_tests_async/test_default.py") + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_cloud_sdk_service_account_async(session): + configure_cloud_sdk(session, SERVICE_ACCOUNT_FILE) + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + session.run("pytest", "system_tests_async/test_default.py") + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_cloud_sdk_authorized_user_async(session): + configure_cloud_sdk(session, AUTHORIZED_USER_FILE) + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + session.run("pytest", "system_tests_async/test_default.py") + + +@nox.session(python=PYTHON_VERSIONS_ASYNC) +def default_cloud_sdk_authorized_user_configured_project_async(session): + configure_cloud_sdk(session, AUTHORIZED_USER_FILE, project=True) + session.env[EXPECT_PROJECT_ENV] = "1" + session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) + session.install(LIBRARY_DIR) + session.run("pytest", "system_tests_async/test_default.py") diff --git a/system_tests/system_tests_async/conftest.py b/system_tests/system_tests_async/conftest.py new file mode 100644 index 000000000..ecff74c96 --- /dev/null +++ b/system_tests/system_tests_async/conftest.py @@ -0,0 +1,108 @@ +# Copyright 2020 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 json +import os + +from google.auth import _helpers +import google.auth.transport.requests +import google.auth.transport.urllib3 +import pytest +import requests +import urllib3 + +import aiohttp +from google.auth.transport import _aiohttp_requests as aiohttp_requests +from system_tests.system_tests_sync import conftest as sync_conftest + +ASYNC_REQUESTS_SESSION = aiohttp.ClientSession() + +ASYNC_REQUESTS_SESSION.verify = False +TOKEN_INFO_URL = "https://www.googleapis.com/oauth2/v3/tokeninfo" + + +@pytest.fixture +def service_account_file(): + """The full path to a valid service account key file.""" + yield sync_conftest.SERVICE_ACCOUNT_FILE + + +@pytest.fixture +def impersonated_service_account_file(): + """The full path to a valid service account key file.""" + yield sync_conftest.IMPERSONATED_SERVICE_ACCOUNT_FILE + + +@pytest.fixture +def authorized_user_file(): + """The full path to a valid authorized user file.""" + yield sync_conftest.AUTHORIZED_USER_FILE + +@pytest.fixture(params=["aiohttp"]) +async def http_request(request): + """A transport.request object.""" + yield aiohttp_requests.Request(ASYNC_REQUESTS_SESSION) + +@pytest.fixture +async def token_info(http_request): + """Returns a function that obtains OAuth2 token info.""" + + async def _token_info(access_token=None, id_token=None): + query_params = {} + + if access_token is not None: + query_params["access_token"] = access_token + elif id_token is not None: + query_params["id_token"] = id_token + else: + raise ValueError("No token specified.") + + url = _helpers.update_query(sync_conftest.TOKEN_INFO_URL, query_params) + + response = await http_request(url=url, method="GET") + data = await response.data.read() + + return json.loads(data.decode("utf-8")) + + yield _token_info + + +@pytest.fixture +async def verify_refresh(http_request): + """Returns a function that verifies that credentials can be refreshed.""" + + async def _verify_refresh(credentials): + if credentials.requires_scopes: + credentials = credentials.with_scopes(["email", "profile"]) + + await credentials.refresh(http_request) + + assert credentials.token + assert credentials.valid + + yield _verify_refresh + + +def verify_environment(): + """Checks to make sure that requisite data files are available.""" + if not os.path.isdir(sync_conftest.DATA_DIR): + raise EnvironmentError( + "In order to run system tests, test data must exist in " + "system_tests/data. See CONTRIBUTING.rst for details." + ) + + +def pytest_configure(config): + """Pytest hook that runs before Pytest collects any tests.""" + verify_environment() diff --git a/system_tests/system_tests_async/test_default.py b/system_tests/system_tests_async/test_default.py new file mode 100644 index 000000000..383cbff01 --- /dev/null +++ b/system_tests/system_tests_async/test_default.py @@ -0,0 +1,30 @@ +# Copyright 2020 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 os +import pytest + +import google.auth + +EXPECT_PROJECT_ID = os.environ.get("EXPECT_PROJECT_ID") + +@pytest.mark.asyncio +async def test_application_default_credentials(verify_refresh): + credentials, project_id = google.auth.default_async() + #breakpoint() + + if EXPECT_PROJECT_ID is not None: + assert project_id is not None + + await verify_refresh(credentials) diff --git a/system_tests/system_tests_async/test_id_token.py b/system_tests/system_tests_async/test_id_token.py new file mode 100644 index 000000000..a21b137b6 --- /dev/null +++ b/system_tests/system_tests_async/test_id_token.py @@ -0,0 +1,25 @@ +# Copyright 2020 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 pytest + +from google.auth import jwt +import google.oauth2._id_token_async + +@pytest.mark.asyncio +async def test_fetch_id_token(http_request): + audience = "https://pubsub.googleapis.com" + token = await google.oauth2._id_token_async.fetch_id_token(http_request, audience) + + _, payload, _, _ = jwt._unverified_decode(token) + assert payload["aud"] == audience diff --git a/system_tests/system_tests_async/test_service_account.py b/system_tests/system_tests_async/test_service_account.py new file mode 100644 index 000000000..c1c16ccd7 --- /dev/null +++ b/system_tests/system_tests_async/test_service_account.py @@ -0,0 +1,53 @@ +# Copyright 2020 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 pytest + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import iam +from google.oauth2 import _service_account_async + + +@pytest.fixture +def credentials(service_account_file): + yield _service_account_async.Credentials.from_service_account_file(service_account_file) + + +@pytest.mark.asyncio +async def test_refresh_no_scopes(http_request, credentials): + """ + We expect the http request to refresh credentials + without scopes provided to throw an error. + """ + with pytest.raises(exceptions.RefreshError): + await credentials.refresh(http_request) + +@pytest.mark.asyncio +async def test_refresh_success(http_request, credentials, token_info): + credentials = credentials.with_scopes(["email", "profile"]) + await credentials.refresh(http_request) + + assert credentials.token + + info = await token_info(credentials.token) + + assert info["email"] == credentials.service_account_email + info_scopes = _helpers.string_to_scopes(info["scope"]) + assert set(info_scopes) == set( + [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + ) diff --git a/system_tests/.gitignore b/system_tests/system_tests_sync/.gitignore similarity index 100% rename from system_tests/.gitignore rename to system_tests/system_tests_sync/.gitignore diff --git a/system_tests/__init__.py b/system_tests/system_tests_sync/__init__.py similarity index 100% rename from system_tests/__init__.py rename to system_tests/system_tests_sync/__init__.py diff --git a/system_tests/app_engine_test_app/.gitignore b/system_tests/system_tests_sync/app_engine_test_app/.gitignore similarity index 100% rename from system_tests/app_engine_test_app/.gitignore rename to system_tests/system_tests_sync/app_engine_test_app/.gitignore diff --git a/system_tests/app_engine_test_app/app.yaml b/system_tests/system_tests_sync/app_engine_test_app/app.yaml similarity index 100% rename from system_tests/app_engine_test_app/app.yaml rename to system_tests/system_tests_sync/app_engine_test_app/app.yaml diff --git a/system_tests/app_engine_test_app/appengine_config.py b/system_tests/system_tests_sync/app_engine_test_app/appengine_config.py similarity index 100% rename from system_tests/app_engine_test_app/appengine_config.py rename to system_tests/system_tests_sync/app_engine_test_app/appengine_config.py diff --git a/system_tests/app_engine_test_app/main.py b/system_tests/system_tests_sync/app_engine_test_app/main.py similarity index 100% rename from system_tests/app_engine_test_app/main.py rename to system_tests/system_tests_sync/app_engine_test_app/main.py diff --git a/system_tests/app_engine_test_app/requirements.txt b/system_tests/system_tests_sync/app_engine_test_app/requirements.txt similarity index 100% rename from system_tests/app_engine_test_app/requirements.txt rename to system_tests/system_tests_sync/app_engine_test_app/requirements.txt diff --git a/system_tests/conftest.py b/system_tests/system_tests_sync/conftest.py similarity index 96% rename from system_tests/conftest.py rename to system_tests/system_tests_sync/conftest.py index 02de84664..37a6fd346 100644 --- a/system_tests/conftest.py +++ b/system_tests/system_tests_sync/conftest.py @@ -24,12 +24,11 @@ HERE = os.path.dirname(__file__) -DATA_DIR = os.path.join(HERE, "data") +DATA_DIR = os.path.join(HERE, "../data") IMPERSONATED_SERVICE_ACCOUNT_FILE = os.path.join( DATA_DIR, "impersonated_service_account.json" ) SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, "service_account.json") -AUTHORIZED_USER_FILE = os.path.join(DATA_DIR, "authorized_user.json") URLLIB3_HTTP = urllib3.PoolManager(retries=False) REQUESTS_SESSION = requests.Session() REQUESTS_SESSION.verify = False diff --git a/system_tests/secrets.tar.enc b/system_tests/system_tests_sync/secrets.tar.enc similarity index 100% rename from system_tests/secrets.tar.enc rename to system_tests/system_tests_sync/secrets.tar.enc diff --git a/system_tests/test_app_engine.py b/system_tests/system_tests_sync/test_app_engine.py similarity index 100% rename from system_tests/test_app_engine.py rename to system_tests/system_tests_sync/test_app_engine.py diff --git a/system_tests/test_compute_engine.py b/system_tests/system_tests_sync/test_compute_engine.py similarity index 100% rename from system_tests/test_compute_engine.py rename to system_tests/system_tests_sync/test_compute_engine.py diff --git a/system_tests/test_default.py b/system_tests/system_tests_sync/test_default.py similarity index 100% rename from system_tests/test_default.py rename to system_tests/system_tests_sync/test_default.py diff --git a/system_tests/test_grpc.py b/system_tests/system_tests_sync/test_grpc.py similarity index 100% rename from system_tests/test_grpc.py rename to system_tests/system_tests_sync/test_grpc.py diff --git a/system_tests/test_id_token.py b/system_tests/system_tests_sync/test_id_token.py similarity index 100% rename from system_tests/test_id_token.py rename to system_tests/system_tests_sync/test_id_token.py diff --git a/system_tests/test_impersonated_credentials.py b/system_tests/system_tests_sync/test_impersonated_credentials.py similarity index 100% rename from system_tests/test_impersonated_credentials.py rename to system_tests/system_tests_sync/test_impersonated_credentials.py diff --git a/system_tests/test_mtls_http.py b/system_tests/system_tests_sync/test_mtls_http.py similarity index 100% rename from system_tests/test_mtls_http.py rename to system_tests/system_tests_sync/test_mtls_http.py diff --git a/system_tests/test_oauth2_credentials.py b/system_tests/system_tests_sync/test_oauth2_credentials.py similarity index 100% rename from system_tests/test_oauth2_credentials.py rename to system_tests/system_tests_sync/test_oauth2_credentials.py diff --git a/system_tests/test_service_account.py b/system_tests/system_tests_sync/test_service_account.py similarity index 100% rename from system_tests/test_service_account.py rename to system_tests/system_tests_sync/test_service_account.py diff --git a/tests_async/__init__.py b/tests_async/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests_async/conftest.py b/tests_async/conftest.py new file mode 100644 index 000000000..b4e90f0e8 --- /dev/null +++ b/tests_async/conftest.py @@ -0,0 +1,51 @@ +# Copyright 2020 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 os +import sys + +import mock +import pytest + + +def pytest_configure(): + """Load public certificate and private key.""" + pytest.data_dir = os.path.join( + os.path.abspath(os.path.join(__file__, "../..")), "tests/data" + ) + + with open(os.path.join(pytest.data_dir, "privatekey.pem"), "rb") as fh: + pytest.private_key_bytes = fh.read() + + with open(os.path.join(pytest.data_dir, "public_cert.pem"), "rb") as fh: + pytest.public_cert_bytes = fh.read() + + +@pytest.fixture +def mock_non_existent_module(monkeypatch): + """Mocks a non-existing module in sys.modules. + + Additionally mocks any non-existing modules specified in the dotted path. + """ + + def _mock_non_existent_module(path): + parts = path.split(".") + partial = [] + for part in parts: + partial.append(part) + current_module = ".".join(partial) + if current_module not in sys.modules: + monkeypatch.setitem(sys.modules, current_module, mock.MagicMock()) + + return _mock_non_existent_module diff --git a/tests_async/oauth2/test__client_async.py b/tests_async/oauth2/test__client_async.py new file mode 100644 index 000000000..458937ac1 --- /dev/null +++ b/tests_async/oauth2/test__client_async.py @@ -0,0 +1,297 @@ +# Copyright 2020 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 datetime +import json + +import mock +import pytest +import six +from six.moves import http_client +from six.moves import urllib + +from google.auth import _helpers +from google.auth import _jwt_async as jwt +from google.auth import exceptions +from google.oauth2 import _client as sync_client +from google.oauth2 import _client_async as _client +from tests.oauth2 import test__client as test_client + + +def test__handle_error_response(): + response_data = json.dumps({"error": "help", "error_description": "I'm alive"}) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client._handle_error_response(response_data) + + assert excinfo.match(r"help: I\'m alive") + + +def test__handle_error_response_non_json(): + response_data = "Help, I'm alive" + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client._handle_error_response(response_data) + + assert excinfo.match(r"Help, I\'m alive") + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test__parse_expiry(unused_utcnow): + result = _client._parse_expiry({"expires_in": 500}) + assert result == datetime.datetime.min + datetime.timedelta(seconds=500) + + +def test__parse_expiry_none(): + assert _client._parse_expiry({}) is None + + +def make_request(response_data, status=http_client.OK): + response = mock.AsyncMock(spec=["transport.Response"]) + response.status = status + data = json.dumps(response_data).encode("utf-8") + response.data = mock.AsyncMock(spec=["__call__", "read"]) + response.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + response.content = mock.AsyncMock(spec=["__call__"], return_value=data) + request = mock.AsyncMock(spec=["transport.Request"]) + request.return_value = response + return request + + +@pytest.mark.asyncio +async def test__token_endpoint_request(): + + request = make_request({"test": "response"}) + + result = await _client._token_endpoint_request( + request, "http://example.com", {"test": "params"} + ) + + # Check request call + request.assert_called_with( + method="POST", + url="http://example.com", + headers={"content-type": "application/x-www-form-urlencoded"}, + body="test=params".encode("utf-8"), + ) + + # Check result + assert result == {"test": "response"} + + +@pytest.mark.asyncio +async def test__token_endpoint_request_error(): + request = make_request({}, status=http_client.BAD_REQUEST) + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request(request, "http://example.com", {}) + + +@pytest.mark.asyncio +async def test__token_endpoint_request_internal_failure_error(): + request = make_request( + {"error_description": "internal_failure"}, status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request( + request, "http://example.com", {"error_description": "internal_failure"} + ) + + request = make_request( + {"error": "internal_failure"}, status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request( + request, "http://example.com", {"error": "internal_failure"} + ) + + +def verify_request_params(request, params): + request_body = request.call_args[1]["body"].decode("utf-8") + request_params = urllib.parse.parse_qs(request_body) + + for key, value in six.iteritems(params): + assert request_params[key][0] == value + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +@pytest.mark.asyncio +async def test_jwt_grant(utcnow): + request = make_request( + {"access_token": "token", "expires_in": 500, "extra": "data"} + ) + + token, expiry, extra_data = await _client.jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check request call + verify_request_params( + request, + {"grant_type": sync_client._JWT_GRANT_TYPE, "assertion": "assertion_value"}, + ) + + # Check result + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError): + await _client.jwt_grant(request, "http://example.com", "assertion_value") + + +@pytest.mark.asyncio +async def test_id_token_jwt_grant(): + now = _helpers.utcnow() + id_token_expiry = _helpers.datetime_to_secs(now) + id_token = jwt.encode(test_client.SIGNER, {"exp": id_token_expiry}).decode("utf-8") + request = make_request({"id_token": id_token, "extra": "data"}) + + token, expiry, extra_data = await _client.id_token_jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check request call + verify_request_params( + request, + {"grant_type": sync_client._JWT_GRANT_TYPE, "assertion": "assertion_value"}, + ) + + # Check result + assert token == id_token + # JWT does not store microseconds + now = now.replace(microsecond=0) + assert expiry == now + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_id_token_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError): + await _client.id_token_jwt_grant( + request, "http://example.com", "assertion_value" + ) + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +@pytest.mark.asyncio +async def test_refresh_grant(unused_utcnow): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + token, refresh_token, expiry, extra_data = await _client.refresh_grant( + request, "http://example.com", "refresh_token", "client_id", "client_secret" + ) + + # Check request call + verify_request_params( + request, + { + "grant_type": sync_client._REFRESH_GRANT_TYPE, + "refresh_token": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + }, + ) + + # Check result + assert token == "token" + assert refresh_token == "new_refresh_token" + assert expiry == datetime.datetime.min + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +@pytest.mark.asyncio +async def test_refresh_grant_with_scopes(unused_utcnow): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + "scope": test_client.SCOPES_AS_STRING, + } + ) + + token, refresh_token, expiry, extra_data = await _client.refresh_grant( + request, + "http://example.com", + "refresh_token", + "client_id", + "client_secret", + test_client.SCOPES_AS_LIST, + ) + + # Check request call. + verify_request_params( + request, + { + "grant_type": sync_client._REFRESH_GRANT_TYPE, + "refresh_token": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "scope": test_client.SCOPES_AS_STRING, + }, + ) + + # Check result. + assert token == "token" + assert refresh_token == "new_refresh_token" + assert expiry == datetime.datetime.min + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_refresh_grant_no_access_token(): + request = make_request( + { + # No access token. + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError): + await _client.refresh_grant( + request, "http://example.com", "refresh_token", "client_id", "client_secret" + ) diff --git a/tests_async/oauth2/test_credentials_async.py b/tests_async/oauth2/test_credentials_async.py new file mode 100644 index 000000000..5c883d614 --- /dev/null +++ b/tests_async/oauth2/test_credentials_async.py @@ -0,0 +1,478 @@ +# Copyright 2020 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 datetime +import json +import os +import pickle +import sys + +import mock +import pytest + +from google.auth import _helpers +from google.auth import exceptions +from google.oauth2 import _credentials_async as _credentials_async +from google.oauth2 import credentials +from tests.oauth2 import test_credentials + + +class TestCredentials: + + TOKEN_URI = "https://example.com/oauth2/token" + REFRESH_TOKEN = "refresh_token" + CLIENT_ID = "client_id" + CLIENT_SECRET = "client_secret" + + @classmethod + def make_credentials(cls): + return _credentials_async.Credentials( + token=None, + refresh_token=cls.REFRESH_TOKEN, + token_uri=cls.TOKEN_URI, + client_id=cls.CLIENT_ID, + client_secret=cls.CLIENT_SECRET, + ) + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes aren't required for these credentials + assert not credentials.requires_scopes + # Test properties + assert credentials.refresh_token == self.REFRESH_TOKEN + assert credentials.token_uri == self.TOKEN_URI + assert credentials.client_id == self.CLIENT_ID + assert credentials.client_secret == self.CLIENT_SECRET + + @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) + @pytest.mark.asyncio + async def test_refresh_success(self, unused_utcnow, refresh_grant): + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = self.make_credentials() + + # Refresh credentials + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + None, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + + # Check that the credentials are valid (have a token and are not + # expired) + assert creds.valid + + @pytest.mark.asyncio + async def test_refresh_no_refresh_token(self): + request = mock.AsyncMock(spec=["transport.Request"]) + credentials_ = _credentials_async.Credentials(token=None, refresh_token=None) + + with pytest.raises(exceptions.RefreshError, match="necessary fields"): + await credentials_.refresh(request) + + request.assert_not_called() + + @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) + @pytest.mark.asyncio + async def test_credentials_with_scopes_requested_refresh_success( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = _credentials_async.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + ) + + # Refresh credentials + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) + @pytest.mark.asyncio + async def test_credentials_with_scopes_returned_refresh_success( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = { + "id_token": mock.sentinel.id_token, + "scopes": " ".join(scopes), + } + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = _credentials_async.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + ) + + # Refresh credentials + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) + @pytest.mark.asyncio + async def test_credentials_with_scopes_refresh_failure_raises_refresh_error( + self, unused_utcnow, refresh_grant + ): + scopes = ["email", "profile"] + scopes_returned = ["email"] + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = { + "id_token": mock.sentinel.id_token, + "scopes": " ".join(scopes_returned), + } + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + creds = _credentials_async.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + scopes=scopes, + ) + + # Refresh credentials + with pytest.raises( + exceptions.RefreshError, match="Not all requested scopes were granted" + ): + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + scopes, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + def test_apply_with_quota_project_id(self): + creds = _credentials_async.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + quota_project_id="quota-project-123", + ) + + headers = {} + creds.apply(headers) + assert headers["x-goog-user-project"] == "quota-project-123" + + def test_apply_with_no_quota_project_id(self): + creds = _credentials_async.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + ) + + headers = {} + creds.apply(headers) + assert "x-goog-user-project" not in headers + + def test_with_quota_project(self): + creds = _credentials_async.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + quota_project_id="quota-project-123", + ) + + new_creds = creds.with_quota_project("new-project-456") + assert new_creds.quota_project_id == "new-project-456" + headers = {} + creds.apply(headers) + assert "x-goog-user-project" in headers + + def test_from_authorized_user_info(self): + info = test_credentials.AUTH_USER_INFO.copy() + + creds = _credentials_async.Credentials.from_authorized_user_info(info) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes is None + + scopes = ["email", "profile"] + creds = _credentials_async.Credentials.from_authorized_user_info(info, scopes) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes == scopes + + def test_from_authorized_user_file(self): + info = test_credentials.AUTH_USER_INFO.copy() + + creds = _credentials_async.Credentials.from_authorized_user_file( + test_credentials.AUTH_USER_JSON_FILE + ) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes is None + + scopes = ["email", "profile"] + creds = _credentials_async.Credentials.from_authorized_user_file( + test_credentials.AUTH_USER_JSON_FILE, scopes + ) + assert creds.client_secret == info["client_secret"] + assert creds.client_id == info["client_id"] + assert creds.refresh_token == info["refresh_token"] + assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert creds.scopes == scopes + + def test_to_json(self): + info = test_credentials.AUTH_USER_INFO.copy() + creds = _credentials_async.Credentials.from_authorized_user_info(info) + + # Test with no `strip` arg + json_output = creds.to_json() + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") == creds.client_secret + + # Test with a `strip` arg + json_output = creds.to_json(strip=["client_secret"]) + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") is None + + def test_pickle_and_unpickle(self): + creds = self.make_credentials() + unpickled = pickle.loads(pickle.dumps(creds)) + + # make sure attributes aren't lost during pickling + assert list(creds.__dict__).sort() == list(unpickled.__dict__).sort() + + for attr in list(creds.__dict__): + assert getattr(creds, attr) == getattr(unpickled, attr) + + def test_pickle_with_missing_attribute(self): + creds = self.make_credentials() + + # remove an optional attribute before pickling + # this mimics a pickle created with a previous class definition with + # fewer attributes + del creds.__dict__["_quota_project_id"] + + unpickled = pickle.loads(pickle.dumps(creds)) + + # Attribute should be initialized by `__setstate__` + assert unpickled.quota_project_id is None + + # pickles are not compatible across versions + @pytest.mark.skipif( + sys.version_info < (3, 5), + reason="pickle file can only be loaded with Python >= 3.5", + ) + def test_unpickle_old_credentials_pickle(self): + # make sure a credentials file pickled with an older + # library version (google-auth==1.5.1) can be unpickled + with open( + os.path.join(test_credentials.DATA_DIR, "old_oauth_credentials_py3.pickle"), + "rb", + ) as f: + credentials = pickle.load(f) + assert credentials.quota_project_id is None + + +class TestUserAccessTokenCredentials(object): + def test_instance(self): + cred = _credentials_async.UserAccessTokenCredentials() + assert cred._account is None + + cred = cred.with_account("account") + assert cred._account == "account" + + @mock.patch("google.auth._cloud_sdk.get_auth_access_token", autospec=True) + def test_refresh(self, get_auth_access_token): + get_auth_access_token.return_value = "access_token" + cred = _credentials_async.UserAccessTokenCredentials() + cred.refresh(None) + assert cred.token == "access_token" + + def test_with_quota_project(self): + cred = _credentials_async.UserAccessTokenCredentials() + quota_project_cred = cred.with_quota_project("project-foo") + + assert quota_project_cred._quota_project_id == "project-foo" + assert quota_project_cred._account == cred._account + + @mock.patch( + "google.oauth2._credentials_async.UserAccessTokenCredentials.apply", + autospec=True, + ) + @mock.patch( + "google.oauth2._credentials_async.UserAccessTokenCredentials.refresh", + autospec=True, + ) + def test_before_request(self, refresh, apply): + cred = _credentials_async.UserAccessTokenCredentials() + cred.before_request(mock.Mock(), "GET", "https://example.com", {}) + refresh.assert_called() + apply.assert_called() diff --git a/tests_async/oauth2/test_id_token.py b/tests_async/oauth2/test_id_token.py new file mode 100644 index 000000000..a46bd615e --- /dev/null +++ b/tests_async/oauth2/test_id_token.py @@ -0,0 +1,205 @@ +# Copyright 2020 Google Inc. +# +# 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 os + +import mock +import pytest + +from google.auth import environment_vars +from google.auth import exceptions +import google.auth.compute_engine._metadata +from google.oauth2 import _id_token_async as id_token +from google.oauth2 import id_token as sync_id_token +from tests.oauth2 import test_id_token + + +def make_request(status, data=None): + response = mock.AsyncMock(spec=["transport.Response"]) + response.status = status + + if data is not None: + response.data = mock.AsyncMock(spec=["__call__", "read"]) + response.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + + request = mock.AsyncMock(spec=["transport.Request"]) + request.return_value = response + return request + + +@pytest.mark.asyncio +async def test__fetch_certs_success(): + certs = {"1": "cert"} + request = make_request(200, certs) + + returned_certs = await id_token._fetch_certs(request, mock.sentinel.cert_url) + + request.assert_called_once_with(mock.sentinel.cert_url, method="GET") + assert returned_certs == certs + + +@pytest.mark.asyncio +async def test__fetch_certs_failure(): + request = make_request(404) + + with pytest.raises(exceptions.TransportError): + await id_token._fetch_certs(request, mock.sentinel.cert_url) + + request.assert_called_once_with(mock.sentinel.cert_url, method="GET") + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2._id_token_async._fetch_certs", autospec=True) +@pytest.mark.asyncio +async def test_verify_token(_fetch_certs, decode): + result = await id_token.verify_token(mock.sentinel.token, mock.sentinel.request) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with( + mock.sentinel.request, sync_id_token._GOOGLE_OAUTH2_CERTS_URL + ) + decode.assert_called_once_with( + mock.sentinel.token, certs=_fetch_certs.return_value, audience=None + ) + + +@mock.patch("google.auth.jwt.decode", autospec=True) +@mock.patch("google.oauth2._id_token_async._fetch_certs", autospec=True) +@pytest.mark.asyncio +async def test_verify_token_args(_fetch_certs, decode): + result = await id_token.verify_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=mock.sentinel.certs_url, + ) + + assert result == decode.return_value + _fetch_certs.assert_called_once_with(mock.sentinel.request, mock.sentinel.certs_url) + decode.assert_called_once_with( + mock.sentinel.token, + certs=_fetch_certs.return_value, + audience=mock.sentinel.audience, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_oauth2_token(verify_token): + verify_token.return_value = {"iss": "accounts.google.com"} + result = await id_token.verify_oauth2_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_oauth2_token_invalid_iss(verify_token): + verify_token.return_value = {"iss": "invalid_issuer"} + + with pytest.raises(exceptions.GoogleAuthError): + await id_token.verify_oauth2_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_firebase_token(verify_token): + result = await id_token.verify_firebase_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) + + assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL, + ) + + +@pytest.mark.asyncio +async def test_fetch_id_token_from_metadata_server(): + def mock_init(self, request, audience, use_metadata_identity_endpoint): + assert use_metadata_identity_endpoint + self.token = "id_token" + + with mock.patch.multiple( + google.auth.compute_engine.IDTokenCredentials, + __init__=mock_init, + refresh=mock.Mock(), + ): + request = mock.AsyncMock() + token = await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +@pytest.mark.asyncio +async def test_fetch_id_token_from_explicit_cred_json_file(mock_init, monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, test_id_token.SERVICE_ACCOUNT_FILE) + + async def mock_refresh(self, request): + self.token = "id_token" + + with mock.patch.object( + google.oauth2._service_account_async.IDTokenCredentials, "refresh", mock_refresh + ): + request = mock.AsyncMock() + token = await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +@pytest.mark.asyncio +async def test_fetch_id_token_no_cred_json_file(mock_init, monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + with pytest.raises(exceptions.DefaultCredentialsError): + request = mock.AsyncMock() + await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +@pytest.mark.asyncio +async def test_fetch_id_token_invalid_cred_file(mock_init, monkeypatch): + not_json_file = os.path.join( + os.path.dirname(__file__), "../../tests/data/public_cert.pem" + ) + monkeypatch.setenv(environment_vars.CREDENTIALS, not_json_file) + + with pytest.raises(exceptions.DefaultCredentialsError): + request = mock.AsyncMock() + await id_token.fetch_id_token(request, "https://pubsub.googleapis.com") diff --git a/tests_async/oauth2/test_service_account_async.py b/tests_async/oauth2/test_service_account_async.py new file mode 100644 index 000000000..40794536c --- /dev/null +++ b/tests_async/oauth2/test_service_account_async.py @@ -0,0 +1,372 @@ +# Copyright 2020 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 datetime + +import mock +import pytest + +from google.auth import _helpers +from google.auth import crypt +from google.auth import jwt +from google.auth import transport +from google.oauth2 import _service_account_async as service_account +from tests.oauth2 import test_service_account + + +class TestCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + TOKEN_URI = "https://example.com/oauth2/token" + + @classmethod + def make_credentials(cls): + return service_account.Credentials( + test_service_account.SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI + ) + + def test_from_service_account_info(self): + credentials = service_account.Credentials.from_service_account_info( + test_service_account.SERVICE_ACCOUNT_INFO + ) + + assert ( + credentials._signer.key_id + == test_service_account.SERVICE_ACCOUNT_INFO["private_key_id"] + ) + assert ( + credentials.service_account_email + == test_service_account.SERVICE_ACCOUNT_INFO["client_email"] + ) + assert ( + credentials._token_uri + == test_service_account.SERVICE_ACCOUNT_INFO["token_uri"] + ) + + def test_from_service_account_info_args(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + scopes = ["email", "profile"] + subject = "subject" + additional_claims = {"meta": "data"} + + credentials = service_account.Credentials.from_service_account_info( + info, scopes=scopes, subject=subject, additional_claims=additional_claims + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + + def test_from_service_account_file(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + + credentials = service_account.Credentials.from_service_account_file( + test_service_account.SERVICE_ACCOUNT_JSON_FILE + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + + def test_from_service_account_file_args(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + scopes = ["email", "profile"] + subject = "subject" + additional_claims = {"meta": "data"} + + credentials = service_account.Credentials.from_service_account_file( + test_service_account.SERVICE_ACCOUNT_JSON_FILE, + subject=subject, + scopes=scopes, + additional_claims=additional_claims, + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes haven't been specified yet + assert credentials.requires_scopes + + def test_sign_bytes(self): + credentials = self.make_credentials() + to_sign = b"123" + signature = credentials.sign_bytes(to_sign) + assert crypt.verify_signature( + to_sign, signature, test_service_account.PUBLIC_CERT_BYTES + ) + + def test_signer(self): + credentials = self.make_credentials() + assert isinstance(credentials.signer, crypt.Signer) + + def test_signer_email(self): + credentials = self.make_credentials() + assert credentials.signer_email == self.SERVICE_ACCOUNT_EMAIL + + def test_create_scoped(self): + credentials = self.make_credentials() + scopes = ["email", "profile"] + credentials = credentials.with_scopes(scopes) + assert credentials._scopes == scopes + + def test_with_claims(self): + credentials = self.make_credentials() + new_credentials = credentials.with_claims({"meep": "moop"}) + assert new_credentials._additional_claims == {"meep": "moop"} + + def test_with_quota_project(self): + credentials = self.make_credentials() + new_credentials = credentials.with_quota_project("new-project-456") + assert new_credentials.quota_project_id == "new-project-456" + hdrs = {} + new_credentials.apply(hdrs, token="tok") + assert "x-goog-user-project" in hdrs + + def test__make_authorization_grant_assertion(self): + credentials = self.make_credentials() + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + assert payload["aud"] == self.TOKEN_URI + + def test__make_authorization_grant_assertion_scoped(self): + credentials = self.make_credentials() + scopes = ["email", "profile"] + credentials = credentials.with_scopes(scopes) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) + assert payload["scope"] == "email profile" + + def test__make_authorization_grant_assertion_subject(self): + credentials = self.make_credentials() + subject = "user@example.com" + credentials = credentials.with_subject(subject) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) + assert payload["sub"] == subject + + @mock.patch("google.oauth2._client_async.jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_refresh_success(self, jwt_grant): + credentials = self.make_credentials() + token = "token" + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Refresh credentials + await credentials.refresh(request) + + # Check jwt grant call. + assert jwt_grant.called + + called_request, token_uri, assertion = jwt_grant.call_args[0] + assert called_request == request + assert token_uri == credentials._token_uri + assert jwt.decode(assertion, test_service_account.PUBLIC_CERT_BYTES) + # No further assertion done on the token, as there are separate tests + # for checking the authorization grant assertion. + + # Check that the credentials have the token. + assert credentials.token == token + + # Check that the credentials are valid (have a token and are not + # expired) + assert credentials.valid + + @mock.patch("google.oauth2._client_async.jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_before_request_refreshes(self, jwt_grant): + credentials = self.make_credentials() + token = "token" + jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + None, + ) + request = mock.create_autospec(transport.Request, instance=True) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + await credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert jwt_grant.called + + # Credentials should now be valid. + assert credentials.valid + + +class TestIDTokenCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + TOKEN_URI = "https://example.com/oauth2/token" + TARGET_AUDIENCE = "https://example.com" + + @classmethod + def make_credentials(cls): + return service_account.IDTokenCredentials( + test_service_account.SIGNER, + cls.SERVICE_ACCOUNT_EMAIL, + cls.TOKEN_URI, + cls.TARGET_AUDIENCE, + ) + + def test_from_service_account_info(self): + credentials = service_account.IDTokenCredentials.from_service_account_info( + test_service_account.SERVICE_ACCOUNT_INFO, + target_audience=self.TARGET_AUDIENCE, + ) + + assert ( + credentials._signer.key_id + == test_service_account.SERVICE_ACCOUNT_INFO["private_key_id"] + ) + assert ( + credentials.service_account_email + == test_service_account.SERVICE_ACCOUNT_INFO["client_email"] + ) + assert ( + credentials._token_uri + == test_service_account.SERVICE_ACCOUNT_INFO["token_uri"] + ) + assert credentials._target_audience == self.TARGET_AUDIENCE + + def test_from_service_account_file(self): + info = test_service_account.SERVICE_ACCOUNT_INFO.copy() + + credentials = service_account.IDTokenCredentials.from_service_account_file( + test_service_account.SERVICE_ACCOUNT_JSON_FILE, + target_audience=self.TARGET_AUDIENCE, + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._target_audience == self.TARGET_AUDIENCE + + def test_default_state(self): + credentials = self.make_credentials() + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + + def test_sign_bytes(self): + credentials = self.make_credentials() + to_sign = b"123" + signature = credentials.sign_bytes(to_sign) + assert crypt.verify_signature( + to_sign, signature, test_service_account.PUBLIC_CERT_BYTES + ) + + def test_signer(self): + credentials = self.make_credentials() + assert isinstance(credentials.signer, crypt.Signer) + + def test_signer_email(self): + credentials = self.make_credentials() + assert credentials.signer_email == self.SERVICE_ACCOUNT_EMAIL + + def test_with_target_audience(self): + credentials = self.make_credentials() + new_credentials = credentials.with_target_audience("https://new.example.com") + assert new_credentials._target_audience == "https://new.example.com" + + def test_with_quota_project(self): + credentials = self.make_credentials() + new_credentials = credentials.with_quota_project("project-foo") + assert new_credentials._quota_project_id == "project-foo" + + def test__make_authorization_grant_assertion(self): + credentials = self.make_credentials() + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + assert payload["aud"] == self.TOKEN_URI + assert payload["target_audience"] == self.TARGET_AUDIENCE + + @mock.patch("google.oauth2._client_async.id_token_jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_refresh_success(self, id_token_jwt_grant): + credentials = self.make_credentials() + token = "token" + id_token_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + + request = mock.AsyncMock(spec=["transport.Request"]) + + # Refresh credentials + await credentials.refresh(request) + + # Check jwt grant call. + assert id_token_jwt_grant.called + + called_request, token_uri, assertion = id_token_jwt_grant.call_args[0] + assert called_request == request + assert token_uri == credentials._token_uri + assert jwt.decode(assertion, test_service_account.PUBLIC_CERT_BYTES) + # No further assertion done on the token, as there are separate tests + # for checking the authorization grant assertion. + + # Check that the credentials have the token. + assert credentials.token == token + + # Check that the credentials are valid (have a token and are not + # expired) + assert credentials.valid + + @mock.patch("google.oauth2._client_async.id_token_jwt_grant", autospec=True) + @pytest.mark.asyncio + async def test_before_request_refreshes(self, id_token_jwt_grant): + credentials = self.make_credentials() + token = "token" + id_token_jwt_grant.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + None, + ) + request = mock.AsyncMock(spec=["transport.Request"]) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + await credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # The refresh endpoint should've been called. + assert id_token_jwt_grant.called + + # Credentials should now be valid. + assert credentials.valid diff --git a/tests_async/test__default_async.py b/tests_async/test__default_async.py new file mode 100644 index 000000000..bca396aee --- /dev/null +++ b/tests_async/test__default_async.py @@ -0,0 +1,468 @@ +# Copyright 2020 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 json +import os + +import mock +import pytest + +from google.auth import _credentials_async as credentials +from google.auth import _default_async as _default +from google.auth import app_engine +from google.auth import compute_engine +from google.auth import environment_vars +from google.auth import exceptions +from google.oauth2 import _service_account_async as service_account +import google.oauth2.credentials +from tests import test__default as test_default + +MOCK_CREDENTIALS = mock.Mock(spec=credentials.CredentialsWithQuotaProject) +MOCK_CREDENTIALS.with_quota_project.return_value = MOCK_CREDENTIALS + +LOAD_FILE_PATCH = mock.patch( + "google.auth._default_async.load_credentials_from_file", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) + + +def test_load_credentials_from_missing_file(): + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file("") + + assert excinfo.match(r"not found") + + +def test_load_credentials_from_file_invalid_json(tmpdir): + jsonfile = tmpdir.join("invalid.json") + jsonfile.write("{") + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(jsonfile)) + + assert excinfo.match(r"not a valid json file") + + +def test_load_credentials_from_file_invalid_type(tmpdir): + jsonfile = tmpdir.join("invalid.json") + jsonfile.write(json.dumps({"type": "not-a-real-type"})) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(jsonfile)) + + assert excinfo.match(r"does not have a valid type") + + +def test_load_credentials_from_file_authorized_user(): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_FILE + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + + +def test_load_credentials_from_file_no_type(tmpdir): + # use the client_secrets.json, which is valid json but not a + # loadable credentials type + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(test_default.CLIENT_SECRETS_FILE) + + assert excinfo.match(r"does not have a valid type") + assert excinfo.match(r"Type is None") + + +def test_load_credentials_from_file_authorized_user_bad_format(tmpdir): + filename = tmpdir.join("authorized_user_bad.json") + filename.write(json.dumps({"type": "authorized_user"})) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(filename)) + + assert excinfo.match(r"Failed to load authorized user") + assert excinfo.match(r"missing fields") + + +def test_load_credentials_from_file_authorized_user_cloud_sdk(): + with pytest.warns(UserWarning, match="Cloud SDK"): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_FILE + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + + # No warning if the json file has quota project id. + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_WITH_QUOTA_PROJECT_ID_FILE + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + + +def test_load_credentials_from_file_authorized_user_cloud_sdk_with_scopes(): + with pytest.warns(UserWarning, match="Cloud SDK"): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_FILE, + scopes=["https://www.google.com/calendar/feeds"], + ) + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + assert credentials.scopes == ["https://www.google.com/calendar/feeds"] + + +def test_load_credentials_from_file_authorized_user_cloud_sdk_with_quota_project(): + credentials, project_id = _default.load_credentials_from_file( + test_default.AUTHORIZED_USER_CLOUD_SDK_FILE, quota_project_id="project-foo" + ) + + assert isinstance(credentials, google.oauth2._credentials_async.Credentials) + assert project_id is None + assert credentials.quota_project_id == "project-foo" + + +def test_load_credentials_from_file_service_account(): + credentials, project_id = _default.load_credentials_from_file( + test_default.SERVICE_ACCOUNT_FILE + ) + assert isinstance(credentials, service_account.Credentials) + assert project_id == test_default.SERVICE_ACCOUNT_FILE_DATA["project_id"] + + +def test_load_credentials_from_file_service_account_with_scopes(): + credentials, project_id = _default.load_credentials_from_file( + test_default.SERVICE_ACCOUNT_FILE, + scopes=["https://www.google.com/calendar/feeds"], + ) + assert isinstance(credentials, service_account.Credentials) + assert project_id == test_default.SERVICE_ACCOUNT_FILE_DATA["project_id"] + assert credentials.scopes == ["https://www.google.com/calendar/feeds"] + + +def test_load_credentials_from_file_service_account_bad_format(tmpdir): + filename = tmpdir.join("serivce_account_bad.json") + filename.write(json.dumps({"type": "service_account"})) + + with pytest.raises(exceptions.DefaultCredentialsError) as excinfo: + _default.load_credentials_from_file(str(filename)) + + assert excinfo.match(r"Failed to load service account") + assert excinfo.match(r"missing fields") + + +@mock.patch.dict(os.environ, {}, clear=True) +def test__get_explicit_environ_credentials_no_env(): + assert _default._get_explicit_environ_credentials() == (None, None) + + +@LOAD_FILE_PATCH +def test__get_explicit_environ_credentials(load, monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, "filename") + + credentials, project_id = _default._get_explicit_environ_credentials() + + assert credentials is MOCK_CREDENTIALS + assert project_id is mock.sentinel.project_id + load.assert_called_with("filename") + + +@LOAD_FILE_PATCH +def test__get_explicit_environ_credentials_no_project_id(load, monkeypatch): + load.return_value = MOCK_CREDENTIALS, None + monkeypatch.setenv(environment_vars.CREDENTIALS, "filename") + + credentials, project_id = _default._get_explicit_environ_credentials() + + assert credentials is MOCK_CREDENTIALS + assert project_id is None + + +@LOAD_FILE_PATCH +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +def test__get_gcloud_sdk_credentials(get_adc_path, load): + get_adc_path.return_value = test_default.SERVICE_ACCOUNT_FILE + + credentials, project_id = _default._get_gcloud_sdk_credentials() + + assert credentials is MOCK_CREDENTIALS + assert project_id is mock.sentinel.project_id + load.assert_called_with(test_default.SERVICE_ACCOUNT_FILE) + + +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", autospec=True +) +def test__get_gcloud_sdk_credentials_non_existent(get_adc_path, tmpdir): + non_existent = tmpdir.join("non-existent") + get_adc_path.return_value = str(non_existent) + + credentials, project_id = _default._get_gcloud_sdk_credentials() + + assert credentials is None + assert project_id is None + + +@mock.patch( + "google.auth._cloud_sdk.get_project_id", + return_value=mock.sentinel.project_id, + autospec=True, +) +@mock.patch("os.path.isfile", return_value=True, autospec=True) +@LOAD_FILE_PATCH +def test__get_gcloud_sdk_credentials_project_id(load, unused_isfile, get_project_id): + # Don't return a project ID from load file, make the function check + # the Cloud SDK project. + load.return_value = MOCK_CREDENTIALS, None + + credentials, project_id = _default._get_gcloud_sdk_credentials() + + assert credentials == MOCK_CREDENTIALS + assert project_id == mock.sentinel.project_id + assert get_project_id.called + + +@mock.patch("google.auth._cloud_sdk.get_project_id", return_value=None, autospec=True) +@mock.patch("os.path.isfile", return_value=True) +@LOAD_FILE_PATCH +def test__get_gcloud_sdk_credentials_no_project_id(load, unused_isfile, get_project_id): + # Don't return a project ID from load file, make the function check + # the Cloud SDK project. + load.return_value = MOCK_CREDENTIALS, None + + credentials, project_id = _default._get_gcloud_sdk_credentials() + + assert credentials == MOCK_CREDENTIALS + assert project_id is None + assert get_project_id.called + + +class _AppIdentityModule(object): + """The interface of the App Idenity app engine module. + See https://cloud.google.com/appengine/docs/standard/python/refdocs\ + /google.appengine.api.app_identity.app_identity + """ + + def get_application_id(self): + raise NotImplementedError() + + +@pytest.fixture +def app_identity(monkeypatch): + """Mocks the app_identity module for google.auth.app_engine.""" + app_identity_module = mock.create_autospec(_AppIdentityModule, instance=True) + monkeypatch.setattr(app_engine, "app_identity", app_identity_module) + yield app_identity_module + + +def test__get_gae_credentials(app_identity): + app_identity.get_application_id.return_value = mock.sentinel.project + + credentials, project_id = _default._get_gae_credentials() + + assert isinstance(credentials, app_engine.Credentials) + assert project_id == mock.sentinel.project + + +def test__get_gae_credentials_no_app_engine(): + import sys + + with mock.patch.dict("sys.modules"): + sys.modules["google.auth.app_engine"] = None + credentials, project_id = _default._get_gae_credentials() + assert credentials is None + assert project_id is None + + +def test__get_gae_credentials_no_apis(): + assert _default._get_gae_credentials() == (None, None) + + +@mock.patch( + "google.auth.compute_engine._metadata.ping", return_value=True, autospec=True +) +@mock.patch( + "google.auth.compute_engine._metadata.get_project_id", + return_value="example-project", + autospec=True, +) +def test__get_gce_credentials(unused_get, unused_ping): + credentials, project_id = _default._get_gce_credentials() + + assert isinstance(credentials, compute_engine.Credentials) + assert project_id == "example-project" + + +@mock.patch( + "google.auth.compute_engine._metadata.ping", return_value=False, autospec=True +) +def test__get_gce_credentials_no_ping(unused_ping): + credentials, project_id = _default._get_gce_credentials() + + assert credentials is None + assert project_id is None + + +@mock.patch( + "google.auth.compute_engine._metadata.ping", return_value=True, autospec=True +) +@mock.patch( + "google.auth.compute_engine._metadata.get_project_id", + side_effect=exceptions.TransportError(), + autospec=True, +) +def test__get_gce_credentials_no_project_id(unused_get, unused_ping): + credentials, project_id = _default._get_gce_credentials() + + assert isinstance(credentials, compute_engine.Credentials) + assert project_id is None + + +def test__get_gce_credentials_no_compute_engine(): + import sys + + with mock.patch.dict("sys.modules"): + sys.modules["google.auth.compute_engine"] = None + credentials, project_id = _default._get_gce_credentials() + assert credentials is None + assert project_id is None + + +@mock.patch( + "google.auth.compute_engine._metadata.ping", return_value=False, autospec=True +) +def test__get_gce_credentials_explicit_request(ping): + _default._get_gce_credentials(mock.sentinel.request) + ping.assert_called_with(request=mock.sentinel.request) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_early_out(unused_get): + assert _default.default_async() == (MOCK_CREDENTIALS, mock.sentinel.project_id) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_explict_project_id(unused_get, monkeypatch): + monkeypatch.setenv(environment_vars.PROJECT, "explicit-env") + assert _default.default_async() == (MOCK_CREDENTIALS, "explicit-env") + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_explict_legacy_project_id(unused_get, monkeypatch): + monkeypatch.setenv(environment_vars.LEGACY_PROJECT, "explicit-env") + assert _default.default_async() == (MOCK_CREDENTIALS, "explicit-env") + + +@mock.patch("logging.Logger.warning", autospec=True) +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gcloud_sdk_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gae_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gce_credentials", + return_value=(MOCK_CREDENTIALS, None), + autospec=True, +) +def test_default_without_project_id( + unused_gce, unused_gae, unused_sdk, unused_explicit, logger_warning +): + assert _default.default_async() == (MOCK_CREDENTIALS, None) + logger_warning.assert_called_with(mock.ANY, mock.ANY, mock.ANY) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(None, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gcloud_sdk_credentials", + return_value=(None, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gae_credentials", + return_value=(None, None), + autospec=True, +) +@mock.patch( + "google.auth._default_async._get_gce_credentials", + return_value=(None, None), + autospec=True, +) +def test_default_fail(unused_gce, unused_gae, unused_sdk, unused_explicit): + with pytest.raises(exceptions.DefaultCredentialsError): + assert _default.default_async() + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +@mock.patch( + "google.auth._credentials_async.with_scopes_if_required", + return_value=MOCK_CREDENTIALS, + autospec=True, +) +def test_default_scoped(with_scopes, unused_get): + scopes = ["one", "two"] + + credentials, project_id = _default.default_async(scopes=scopes) + + assert credentials == with_scopes.return_value + assert project_id == mock.sentinel.project_id + with_scopes.assert_called_once_with(MOCK_CREDENTIALS, scopes) + + +@mock.patch( + "google.auth._default_async._get_explicit_environ_credentials", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_no_app_engine_compute_engine_module(unused_get): + """ + google.auth.compute_engine and google.auth.app_engine are both optional + to allow not including them when using this package. This verifies + that default fails gracefully if these modules are absent + """ + import sys + + with mock.patch.dict("sys.modules"): + sys.modules["google.auth.compute_engine"] = None + sys.modules["google.auth.app_engine"] = None + assert _default.default_async() == (MOCK_CREDENTIALS, mock.sentinel.project_id) diff --git a/tests_async/test_credentials_async.py b/tests_async/test_credentials_async.py new file mode 100644 index 000000000..0a4890825 --- /dev/null +++ b/tests_async/test_credentials_async.py @@ -0,0 +1,177 @@ +# Copyright 2020 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 datetime + +import pytest + +from google.auth import _credentials_async as credentials +from google.auth import _helpers + + +class CredentialsImpl(credentials.Credentials): + def refresh(self, request): + self.token = request + + def with_quota_project(self, quota_project_id): + raise NotImplementedError() + + +def test_credentials_constructor(): + credentials = CredentialsImpl() + assert not credentials.token + assert not credentials.expiry + assert not credentials.expired + assert not credentials.valid + + +def test_expired_and_valid(): + credentials = CredentialsImpl() + credentials.token = "token" + + assert credentials.valid + assert not credentials.expired + + # Set the expiration to one second more than now plus the clock skew + # accomodation. These credentials should be valid. + credentials.expiry = ( + datetime.datetime.utcnow() + _helpers.CLOCK_SKEW + datetime.timedelta(seconds=1) + ) + + assert credentials.valid + assert not credentials.expired + + # Set the credentials expiration to now. Because of the clock skew + # accomodation, these credentials should report as expired. + credentials.expiry = datetime.datetime.utcnow() + + assert not credentials.valid + assert credentials.expired + + +@pytest.mark.asyncio +async def test_before_request(): + credentials = CredentialsImpl() + request = "token" + headers = {} + + # First call should call refresh, setting the token. + await credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "token" + assert headers["authorization"] == "Bearer token" + + request = "token2" + headers = {} + + # Second call shouldn't call refresh. + credentials.before_request(request, "http://example.com", "GET", headers) + + assert credentials.valid + assert credentials.token == "token" + + +def test_anonymous_credentials_ctor(): + anon = credentials.AnonymousCredentials() + + assert anon.token is None + assert anon.expiry is None + assert not anon.expired + assert anon.valid + + +def test_anonymous_credentials_refresh(): + anon = credentials.AnonymousCredentials() + + request = object() + with pytest.raises(ValueError): + anon.refresh(request) + + +def test_anonymous_credentials_apply_default(): + anon = credentials.AnonymousCredentials() + headers = {} + anon.apply(headers) + assert headers == {} + with pytest.raises(ValueError): + anon.apply(headers, token="TOKEN") + + +def test_anonymous_credentials_before_request(): + anon = credentials.AnonymousCredentials() + request = object() + method = "GET" + url = "https://example.com/api/endpoint" + headers = {} + anon.before_request(request, method, url, headers) + assert headers == {} + + +class ReadOnlyScopedCredentialsImpl(credentials.ReadOnlyScoped, CredentialsImpl): + @property + def requires_scopes(self): + return super(ReadOnlyScopedCredentialsImpl, self).requires_scopes + + +def test_readonly_scoped_credentials_constructor(): + credentials = ReadOnlyScopedCredentialsImpl() + assert credentials._scopes is None + + +def test_readonly_scoped_credentials_scopes(): + credentials = ReadOnlyScopedCredentialsImpl() + credentials._scopes = ["one", "two"] + assert credentials.scopes == ["one", "two"] + assert credentials.has_scopes(["one"]) + assert credentials.has_scopes(["two"]) + assert credentials.has_scopes(["one", "two"]) + assert not credentials.has_scopes(["three"]) + + +def test_readonly_scoped_credentials_requires_scopes(): + credentials = ReadOnlyScopedCredentialsImpl() + assert not credentials.requires_scopes + + +class RequiresScopedCredentialsImpl(credentials.Scoped, CredentialsImpl): + def __init__(self, scopes=None): + super(RequiresScopedCredentialsImpl, self).__init__() + self._scopes = scopes + + @property + def requires_scopes(self): + return not self.scopes + + def with_scopes(self, scopes): + return RequiresScopedCredentialsImpl(scopes=scopes) + + +def test_create_scoped_if_required_scoped(): + unscoped_credentials = RequiresScopedCredentialsImpl() + scoped_credentials = credentials.with_scopes_if_required( + unscoped_credentials, ["one", "two"] + ) + + assert scoped_credentials is not unscoped_credentials + assert not scoped_credentials.requires_scopes + assert scoped_credentials.has_scopes(["one", "two"]) + + +def test_create_scoped_if_required_not_scopes(): + unscoped_credentials = CredentialsImpl() + scoped_credentials = credentials.with_scopes_if_required( + unscoped_credentials, ["one", "two"] + ) + + assert scoped_credentials is unscoped_credentials diff --git a/tests_async/test_jwt_async.py b/tests_async/test_jwt_async.py new file mode 100644 index 000000000..a35b837b7 --- /dev/null +++ b/tests_async/test_jwt_async.py @@ -0,0 +1,356 @@ +# Copyright 2020 Google Inc. +# +# 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 datetime +import json + +import mock +import pytest + +from google.auth import _jwt_async as jwt_async +from google.auth import crypt +from google.auth import exceptions +from tests import test_jwt + + +@pytest.fixture +def signer(): + return crypt.RSASigner.from_string(test_jwt.PRIVATE_KEY_BYTES, "1") + + +class TestCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + SUBJECT = "subject" + AUDIENCE = "audience" + ADDITIONAL_CLAIMS = {"meta": "data"} + credentials = None + + @pytest.fixture(autouse=True) + def credentials_fixture(self, signer): + self.credentials = jwt_async.Credentials( + signer, + self.SERVICE_ACCOUNT_EMAIL, + self.SERVICE_ACCOUNT_EMAIL, + self.AUDIENCE, + ) + + def test_from_service_account_info(self): + with open(test_jwt.SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + info = json.load(fh) + + credentials = jwt_async.Credentials.from_service_account_info( + info, audience=self.AUDIENCE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + assert credentials._audience == self.AUDIENCE + + def test_from_service_account_info_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.Credentials.from_service_account_info( + info, + subject=self.SUBJECT, + audience=self.AUDIENCE, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_service_account_file(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.Credentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE, audience=self.AUDIENCE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + assert credentials._audience == self.AUDIENCE + + def test_from_service_account_file_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.Credentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE, + subject=self.SUBJECT, + audience=self.AUDIENCE, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_signing_credentials(self): + jwt_from_signing = self.credentials.from_signing_credentials( + self.credentials, audience=mock.sentinel.new_audience + ) + jwt_from_info = jwt_async.Credentials.from_service_account_info( + test_jwt.SERVICE_ACCOUNT_INFO, audience=mock.sentinel.new_audience + ) + + assert isinstance(jwt_from_signing, jwt_async.Credentials) + assert jwt_from_signing._signer.key_id == jwt_from_info._signer.key_id + assert jwt_from_signing._issuer == jwt_from_info._issuer + assert jwt_from_signing._subject == jwt_from_info._subject + assert jwt_from_signing._audience == jwt_from_info._audience + + def test_default_state(self): + assert not self.credentials.valid + # Expiration hasn't been set yet + assert not self.credentials.expired + + def test_with_claims(self): + new_audience = "new_audience" + new_credentials = self.credentials.with_claims(audience=new_audience) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._audience == new_audience + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == self.credentials._quota_project_id + + def test_with_quota_project(self): + quota_project_id = "project-foo" + + new_credentials = self.credentials.with_quota_project(quota_project_id) + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._audience == self.credentials._audience + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == quota_project_id + + def test_sign_bytes(self): + to_sign = b"123" + signature = self.credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, test_jwt.PUBLIC_CERT_BYTES) + + def test_signer(self): + assert isinstance(self.credentials.signer, crypt.RSASigner) + + def test_signer_email(self): + assert ( + self.credentials.signer_email + == test_jwt.SERVICE_ACCOUNT_INFO["client_email"] + ) + + def _verify_token(self, token): + payload = jwt_async.decode(token, test_jwt.PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + return payload + + def test_refresh(self): + self.credentials.refresh(None) + assert self.credentials.valid + assert not self.credentials.expired + + def test_expired(self): + assert not self.credentials.expired + + self.credentials.refresh(None) + assert not self.credentials.expired + + with mock.patch("google.auth._helpers.utcnow") as now: + one_day = datetime.timedelta(days=1) + now.return_value = self.credentials.expiry + one_day + assert self.credentials.expired + + @pytest.mark.asyncio + async def test_before_request(self): + headers = {} + + self.credentials.refresh(None) + await self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", headers + ) + + header_value = headers["authorization"] + _, token = header_value.split(" ") + + # Since the audience is set, it should use the existing token. + assert token.encode("utf-8") == self.credentials.token + + payload = self._verify_token(token) + assert payload["aud"] == self.AUDIENCE + + @pytest.mark.asyncio + async def test_before_request_refreshes(self): + assert not self.credentials.valid + await self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", {} + ) + assert self.credentials.valid + + +class TestOnDemandCredentials(object): + SERVICE_ACCOUNT_EMAIL = "service-account@example.com" + SUBJECT = "subject" + ADDITIONAL_CLAIMS = {"meta": "data"} + credentials = None + + @pytest.fixture(autouse=True) + def credentials_fixture(self, signer): + self.credentials = jwt_async.OnDemandCredentials( + signer, + self.SERVICE_ACCOUNT_EMAIL, + self.SERVICE_ACCOUNT_EMAIL, + max_cache_size=2, + ) + + def test_from_service_account_info(self): + with open(test_jwt.SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + info = json.load(fh) + + credentials = jwt_async.OnDemandCredentials.from_service_account_info(info) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + + def test_from_service_account_info_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.OnDemandCredentials.from_service_account_info( + info, subject=self.SUBJECT, additional_claims=self.ADDITIONAL_CLAIMS + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_service_account_file(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.OnDemandCredentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == info["client_email"] + + def test_from_service_account_file_args(self): + info = test_jwt.SERVICE_ACCOUNT_INFO.copy() + + credentials = jwt_async.OnDemandCredentials.from_service_account_file( + test_jwt.SERVICE_ACCOUNT_JSON_FILE, + subject=self.SUBJECT, + additional_claims=self.ADDITIONAL_CLAIMS, + ) + + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._issuer == info["client_email"] + assert credentials._subject == self.SUBJECT + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS + + def test_from_signing_credentials(self): + jwt_from_signing = self.credentials.from_signing_credentials(self.credentials) + jwt_from_info = jwt_async.OnDemandCredentials.from_service_account_info( + test_jwt.SERVICE_ACCOUNT_INFO + ) + + assert isinstance(jwt_from_signing, jwt_async.OnDemandCredentials) + assert jwt_from_signing._signer.key_id == jwt_from_info._signer.key_id + assert jwt_from_signing._issuer == jwt_from_info._issuer + assert jwt_from_signing._subject == jwt_from_info._subject + + def test_default_state(self): + # Credentials are *always* valid. + assert self.credentials.valid + # Credentials *never* expire. + assert not self.credentials.expired + + def test_with_claims(self): + new_claims = {"meep": "moop"} + new_credentials = self.credentials.with_claims(additional_claims=new_claims) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._additional_claims == new_claims + + def test_with_quota_project(self): + quota_project_id = "project-foo" + new_credentials = self.credentials.with_quota_project(quota_project_id) + + assert new_credentials._signer == self.credentials._signer + assert new_credentials._issuer == self.credentials._issuer + assert new_credentials._subject == self.credentials._subject + assert new_credentials._additional_claims == self.credentials._additional_claims + assert new_credentials._quota_project_id == quota_project_id + + def test_sign_bytes(self): + to_sign = b"123" + signature = self.credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, test_jwt.PUBLIC_CERT_BYTES) + + def test_signer(self): + assert isinstance(self.credentials.signer, crypt.RSASigner) + + def test_signer_email(self): + assert ( + self.credentials.signer_email + == test_jwt.SERVICE_ACCOUNT_INFO["client_email"] + ) + + def _verify_token(self, token): + payload = jwt_async.decode(token, test_jwt.PUBLIC_CERT_BYTES) + assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL + return payload + + def test_refresh(self): + with pytest.raises(exceptions.RefreshError): + self.credentials.refresh(None) + + def test_before_request(self): + headers = {} + + self.credentials.before_request( + None, "GET", "http://example.com?a=1#3", headers + ) + + _, token = headers["authorization"].split(" ") + payload = self._verify_token(token) + + assert payload["aud"] == "http://example.com" + + # Making another request should re-use the same token. + self.credentials.before_request(None, "GET", "http://example.com?b=2", headers) + + _, new_token = headers["authorization"].split(" ") + + assert new_token == token + + def test_expired_token(self): + self.credentials._cache["audience"] = ( + mock.sentinel.token, + datetime.datetime.min, + ) + + token = self.credentials._get_jwt_for_audience("audience") + + assert token != mock.sentinel.token diff --git a/tests_async/transport/__init__.py b/tests_async/transport/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests_async/transport/async_compliance.py b/tests_async/transport/async_compliance.py new file mode 100644 index 000000000..9c4b173c2 --- /dev/null +++ b/tests_async/transport/async_compliance.py @@ -0,0 +1,133 @@ +# Copyright 2020 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 time + +import flask +import pytest +from pytest_localserver.http import WSGIServer +from six.moves import http_client + +from google.auth import exceptions +from tests.transport import compliance + + +class RequestResponseTests(object): + @pytest.fixture(scope="module") + def server(self): + """Provides a test HTTP server. + + The test server is automatically created before + a test and destroyed at the end. The server is serving a test + application that can be used to verify requests. + """ + app = flask.Flask(__name__) + app.debug = True + + # pylint: disable=unused-variable + # (pylint thinks the flask routes are unusued.) + @app.route("/basic") + def index(): + header_value = flask.request.headers.get("x-test-header", "value") + headers = {"X-Test-Header": header_value} + return "Basic Content", http_client.OK, headers + + @app.route("/server_error") + def server_error(): + return "Error", http_client.INTERNAL_SERVER_ERROR + + @app.route("/wait") + def wait(): + time.sleep(3) + return "Waited" + + # pylint: enable=unused-variable + + server = WSGIServer(application=app.wsgi_app) + server.start() + yield server + server.stop() + + @pytest.mark.asyncio + async def test_request_basic(self, server): + request = self.make_request() + response = await request(url=server.url + "/basic", method="GET") + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + + # Use 13 as this is the length of the data written into the stream. + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_basic_with_http(self, server): + request = self.make_with_parameter_request() + response = await request(url=server.url + "/basic", method="GET") + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + + # Use 13 as this is the length of the data written into the stream. + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_with_timeout_success(self, server): + request = self.make_request() + response = await request(url=server.url + "/basic", method="GET", timeout=2) + + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "value" + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_with_timeout_failure(self, server): + request = self.make_request() + + with pytest.raises(exceptions.TransportError): + await request(url=server.url + "/wait", method="GET", timeout=1) + + @pytest.mark.asyncio + async def test_request_headers(self, server): + request = self.make_request() + response = await request( + url=server.url + "/basic", + method="GET", + headers={"x-test-header": "hello world"}, + ) + + assert response.status == http_client.OK + assert response.headers["x-test-header"] == "hello world" + + data = await response.data.read(13) + assert data == b"Basic Content" + + @pytest.mark.asyncio + async def test_request_error(self, server): + request = self.make_request() + + response = await request(url=server.url + "/server_error", method="GET") + assert response.status == http_client.INTERNAL_SERVER_ERROR + data = await response.data.read(5) + assert data == b"Error" + + @pytest.mark.asyncio + async def test_connection_error(self): + request = self.make_request() + + with pytest.raises(exceptions.TransportError): + await request(url="http://{}".format(compliance.NXDOMAIN), method="GET") diff --git a/tests_async/transport/test_aiohttp_requests.py b/tests_async/transport/test_aiohttp_requests.py new file mode 100644 index 000000000..10c31db8f --- /dev/null +++ b/tests_async/transport/test_aiohttp_requests.py @@ -0,0 +1,245 @@ +# Copyright 2020 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 aiohttp +from aioresponses import aioresponses, core +import mock +import pytest +from tests_async.transport import async_compliance + +import google.auth._credentials_async +from google.auth.transport import _aiohttp_requests as aiohttp_requests +import google.auth.transport._mtls_helper + + +class TestCombinedResponse: + @pytest.mark.asyncio + async def test__is_compressed(self): + response = core.CallbackResult(headers={"Content-Encoding": "gzip"}) + combined_response = aiohttp_requests._CombinedResponse(response) + compressed = combined_response._is_compressed() + assert compressed + + def test__is_compressed_not(self): + response = core.CallbackResult(headers={"Content-Encoding": "not"}) + combined_response = aiohttp_requests._CombinedResponse(response) + compressed = combined_response._is_compressed() + assert not compressed + + @pytest.mark.asyncio + async def test_raw_content(self): + + mock_response = mock.AsyncMock() + mock_response.content.read.return_value = mock.sentinel.read + combined_response = aiohttp_requests._CombinedResponse(response=mock_response) + raw_content = await combined_response.raw_content() + assert raw_content == mock.sentinel.read + + # Second call to validate the preconfigured path. + combined_response._raw_content = mock.sentinel.stored_raw + raw_content = await combined_response.raw_content() + assert raw_content == mock.sentinel.stored_raw + + @pytest.mark.asyncio + async def test_content(self): + mock_response = mock.AsyncMock() + mock_response.content.read.return_value = mock.sentinel.read + combined_response = aiohttp_requests._CombinedResponse(response=mock_response) + content = await combined_response.content() + assert content == mock.sentinel.read + + @mock.patch( + "google.auth.transport._aiohttp_requests.urllib3.response.MultiDecoder.decompress", + return_value="decompressed", + autospec=True, + ) + @pytest.mark.asyncio + async def test_content_compressed(self, urllib3_mock): + rm = core.RequestMatch( + "url", headers={"Content-Encoding": "gzip"}, payload="compressed" + ) + response = await rm.build_response(core.URL("url")) + + combined_response = aiohttp_requests._CombinedResponse(response=response) + content = await combined_response.content() + + urllib3_mock.assert_called_once() + assert content == "decompressed" + + +class TestResponse: + def test_ctor(self): + response = aiohttp_requests._Response(mock.sentinel.response) + assert response._response == mock.sentinel.response + + @pytest.mark.asyncio + async def test_headers_prop(self): + rm = core.RequestMatch("url", headers={"Content-Encoding": "header prop"}) + mock_response = await rm.build_response(core.URL("url")) + + response = aiohttp_requests._Response(mock_response) + assert response.headers["Content-Encoding"] == "header prop" + + @pytest.mark.asyncio + async def test_status_prop(self): + rm = core.RequestMatch("url", status=123) + mock_response = await rm.build_response(core.URL("url")) + response = aiohttp_requests._Response(mock_response) + assert response.status == 123 + + @pytest.mark.asyncio + async def test_data_prop(self): + mock_response = mock.AsyncMock() + mock_response.content.read.return_value = mock.sentinel.read + response = aiohttp_requests._Response(mock_response) + data = await response.data.read() + assert data == mock.sentinel.read + + +class TestRequestResponse(async_compliance.RequestResponseTests): + def make_request(self): + return aiohttp_requests.Request() + + def make_with_parameter_request(self): + http = mock.create_autospec(aiohttp.ClientSession, instance=True) + return aiohttp_requests.Request(http) + + def test_timeout(self): + http = mock.create_autospec(aiohttp.ClientSession, instance=True) + request = aiohttp_requests.Request(http) + request(url="http://example.com", method="GET", timeout=5) + + +class CredentialsStub(google.auth._credentials_async.Credentials): + def __init__(self, token="token"): + super(CredentialsStub, self).__init__() + self.token = token + + def apply(self, headers, token=None): + headers["authorization"] = self.token + + def refresh(self, request): + self.token += "1" + + +class TestAuthorizedSession(object): + TEST_URL = "http://example.com/" + method = "GET" + + def test_constructor(self): + authed_session = aiohttp_requests.AuthorizedSession(mock.sentinel.credentials) + assert authed_session.credentials == mock.sentinel.credentials + + def test_constructor_with_auth_request(self): + http = mock.create_autospec(aiohttp.ClientSession) + auth_request = aiohttp_requests.Request(http) + + authed_session = aiohttp_requests.AuthorizedSession( + mock.sentinel.credentials, auth_request=auth_request + ) + + assert authed_session._auth_request == auth_request + + @pytest.mark.asyncio + async def test_request(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + + mocked.get(self.TEST_URL, status=200, body="test") + session = aiohttp_requests.AuthorizedSession(credentials) + resp = await session.request( + "GET", + "http://example.com/", + headers={"Keep-Alive": "timeout=5, max=1000", "fake": b"bytes"}, + ) + + assert resp.status == 200 + assert "test" == await resp.text() + + await session.close() + + @pytest.mark.asyncio + async def test_ctx(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + mocked.get("http://test.example.com", payload=dict(foo="bar")) + session = aiohttp_requests.AuthorizedSession(credentials) + resp = await session.request("GET", "http://test.example.com") + data = await resp.json() + + assert dict(foo="bar") == data + + await session.close() + + @pytest.mark.asyncio + async def test_http_headers(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + mocked.post( + "http://example.com", + payload=dict(), + headers=dict(connection="keep-alive"), + ) + + session = aiohttp_requests.AuthorizedSession(credentials) + resp = await session.request("POST", "http://example.com") + + assert resp.headers["Connection"] == "keep-alive" + + await session.close() + + @pytest.mark.asyncio + async def test_regexp_example(self): + with aioresponses() as mocked: + credentials = mock.Mock(wraps=CredentialsStub()) + mocked.get("http://example.com", status=500) + mocked.get("http://example.com", status=200) + + session1 = aiohttp_requests.AuthorizedSession(credentials) + + resp1 = await session1.request("GET", "http://example.com") + session2 = aiohttp_requests.AuthorizedSession(credentials) + resp2 = await session2.request("GET", "http://example.com") + + assert resp1.status == 500 + assert resp2.status == 200 + + await session1.close() + await session2.close() + + @pytest.mark.asyncio + async def test_request_no_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + with aioresponses() as mocked: + mocked.get("http://example.com", status=200) + authed_session = aiohttp_requests.AuthorizedSession(credentials) + response = await authed_session.request("GET", "http://example.com") + assert response.status == 200 + assert credentials.before_request.called + assert not credentials.refresh.called + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_refresh(self): + credentials = mock.Mock(wraps=CredentialsStub()) + with aioresponses() as mocked: + mocked.get("http://example.com", status=401) + mocked.get("http://example.com", status=200) + authed_session = aiohttp_requests.AuthorizedSession(credentials) + response = await authed_session.request("GET", "http://example.com") + assert credentials.refresh.called + assert response.status == 200 + + await authed_session.close() From ee5617ccfb7caa6ba652c9cc07f716af31eb6ac0 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2020 15:41:41 -0700 Subject: [PATCH 10/35] chore: release 1.22.0 (#615) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d57047990..94dd92594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-auth/#history +## [1.22.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.3...v1.22.0) (2020-09-28) + + +### Features + +* add asyncio based auth flow ([#612](https://www.github.com/googleapis/google-auth-library-python/issues/612)) ([7e15258](https://www.github.com/googleapis/google-auth-library-python/commit/7e1525822d51bd9ce7dffca42d71313e6e776fcd)), closes [#572](https://www.github.com/googleapis/google-auth-library-python/issues/572) + ### [1.21.3](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.2...v1.21.3) (2020-09-22) diff --git a/setup.py b/setup.py index dd58f30f2..bbc892312 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.21.3" +version = "1.22.0" setup( name="google-auth", From a9240111e7af29338624d98ee10aed31462f4d19 Mon Sep 17 00:00:00 2001 From: Christopher Wilcox Date: Mon, 5 Oct 2020 10:08:02 -0700 Subject: [PATCH 11/35] fix: move aiohttp to extra as it is currently internal surface (#619) Fix #618. Removes aiohttp from required dependencies to lessen dependency tree for google-auth. This will need to be looked at again as more folks use aiohttp and once the surfaces goes to public visibility. --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bbc892312..ee2c8466b 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,9 @@ 'rsa>=3.1.4,<5; python_version >= "3.5"', "setuptools>=40.3.0", "six>=1.9.0", - 'aiohttp >= 3.6.2, < 4.0.0dev; python_version>="3.6"', ) +extras = {"aiohttp": "aiohttp >= 3.6.2, < 4.0.0dev; python_version>='3.6'"} with io.open("README.rst", "r") as fh: long_description = fh.read() @@ -47,6 +47,7 @@ packages=find_packages(exclude=("tests*", "system_tests*")), namespace_packages=("google",), install_requires=DEPENDENCIES, + extras_require=extras, python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*", license="Apache 2.0", keywords="google auth oauth client", From 7f957bae8bc6ded2f4ed0383031c8d03ce386a06 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2020 10:53:00 -0700 Subject: [PATCH 12/35] chore: release 1.22.1 (#620) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94dd92594..d05e4e7d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-auth/#history +### [1.22.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.22.0...v1.22.1) (2020-10-05) + + +### Bug Fixes + +* move aiohttp to extra as it is currently internal surface ([#619](https://www.github.com/googleapis/google-auth-library-python/issues/619)) ([a924011](https://www.github.com/googleapis/google-auth-library-python/commit/a9240111e7af29338624d98ee10aed31462f4d19)), closes [#618](https://www.github.com/googleapis/google-auth-library-python/issues/618) + ## [1.22.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.21.3...v1.22.0) (2020-09-28) diff --git a/setup.py b/setup.py index ee2c8466b..522b98103 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.22.0" +version = "1.22.1" setup( name="google-auth", From 6407258956ec42e3b722418cb7f366e5ae9272ec Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 9 Oct 2020 00:58:16 +0300 Subject: [PATCH 13/35] fix: remove checks for ancient versions of Cryptography (#596) Refs https://github.com/googleapis/google-auth-library-python/issues/595#issuecomment-683903062 I see no point in checking whether someone is running a version of https://github.com/pyca/cryptography/ from 2014 that doesn't even compile against modern versions of OpenSSL anymore. --- google/auth/crypt/_cryptography_rsa.py | 13 ------------- google/auth/crypt/es256.py | 12 ------------ 2 files changed, 25 deletions(-) diff --git a/google/auth/crypt/_cryptography_rsa.py b/google/auth/crypt/_cryptography_rsa.py index e94bc681e..916c9d80a 100644 --- a/google/auth/crypt/_cryptography_rsa.py +++ b/google/auth/crypt/_cryptography_rsa.py @@ -25,23 +25,10 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding import cryptography.x509 -import pkg_resources from google.auth import _helpers from google.auth.crypt import base -_IMPORT_ERROR_MSG = ( - "cryptography>=1.4.0 is required to use cryptography-based RSA " "implementation." -) - -try: # pragma: NO COVER - release = pkg_resources.get_distribution("cryptography").parsed_version - if release < pkg_resources.parse_version("1.4.0"): - raise ImportError(_IMPORT_ERROR_MSG) -except pkg_resources.DistributionNotFound: # pragma: NO COVER - raise ImportError(_IMPORT_ERROR_MSG) - - _CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" _BACKEND = backends.default_backend() _PADDING = padding.PKCS1v15() diff --git a/google/auth/crypt/es256.py b/google/auth/crypt/es256.py index 6955efcc5..c6d617606 100644 --- a/google/auth/crypt/es256.py +++ b/google/auth/crypt/es256.py @@ -25,22 +25,10 @@ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature import cryptography.x509 -import pkg_resources from google.auth import _helpers from google.auth.crypt import base -_IMPORT_ERROR_MSG = ( - "cryptography>=1.4.0 is required to use cryptography-based ECDSA " "algorithms" -) - -try: # pragma: NO COVER - release = pkg_resources.get_distribution("cryptography").parsed_version - if release < pkg_resources.parse_version("1.4.0"): - raise ImportError(_IMPORT_ERROR_MSG) -except pkg_resources.DistributionNotFound: # pragma: NO COVER - raise ImportError(_IMPORT_ERROR_MSG) - _CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" _BACKEND = backends.default_backend() From 3b3172ef94c110c81a49bc160123e8ff55141e65 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 22 Oct 2020 16:05:20 -0400 Subject: [PATCH 14/35] tests: fix unit tests on python 3.6 / 3.7 (#630) --- noxfile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index d497f5305..b92f4939d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -30,7 +30,7 @@ "grpcio", ] -ASYNC_DEPENDENCIES = ["pytest-asyncio", "aioresponses"] +ASYNC_DEPENDENCIES = ["pytest-asyncio", "aioresponses", "asynctest"] BLACK_VERSION = "black==19.3b0" BLACK_PATHS = [ @@ -144,6 +144,7 @@ def docs(session): @nox.session(python="pypy") def pypy(session): session.install(*TEST_DEPENDENCIES) + session.install(*ASYNC_DEPENDENCIES) session.install(".") session.run( "pytest", From 5906c8583ca351b5385a079a30521a9a8a0c7c59 Mon Sep 17 00:00:00 2001 From: David Buxton Date: Fri, 23 Oct 2020 03:06:04 +0100 Subject: [PATCH 15/35] Change metadata service helper to work with any query parameters (#588) Part of #579 This helper is used with '?recursive=true' in one place, and can now be used by IDTokenCredentials for requests with query parameters to the metadata identity end-point. This change will allow making requests to the token end-point with '?scopes=..' query parameters. --- google/auth/compute_engine/_metadata.py | 17 +++++---- google/auth/compute_engine/credentials.py | 9 ++--- tests/compute_engine/test__metadata.py | 43 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/google/auth/compute_engine/_metadata.py b/google/auth/compute_engine/_metadata.py index fe821418e..94e4ffbf0 100644 --- a/google/auth/compute_engine/_metadata.py +++ b/google/auth/compute_engine/_metadata.py @@ -108,7 +108,9 @@ def ping(request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=3): return False -def get(request, path, root=_METADATA_ROOT, recursive=False, retry_count=5): +def get( + request, path, root=_METADATA_ROOT, params=None, recursive=False, retry_count=5 +): """Fetch a resource from the metadata server. Args: @@ -117,6 +119,8 @@ def get(request, path, root=_METADATA_ROOT, recursive=False, retry_count=5): path (str): The resource to retrieve. For example, ``'instance/service-accounts/default'``. root (str): The full path to the metadata server root. + params (Optional[Mapping[str, str]]): A mapping of query parameter + keys to values. recursive (bool): Whether to do a recursive query of metadata. See https://cloud.google.com/compute/docs/metadata#aggcontents for more details. @@ -133,7 +137,7 @@ def get(request, path, root=_METADATA_ROOT, recursive=False, retry_count=5): retrieving metadata. """ base_url = urlparse.urljoin(root, path) - query_params = {} + query_params = {} if params is None else params if recursive: query_params["recursive"] = "true" @@ -224,11 +228,10 @@ def get_service_account_info(request, service_account="default"): google.auth.exceptions.TransportError: if an error occurred while retrieving metadata. """ - return get( - request, - "instance/service-accounts/{0}/".format(service_account), - recursive=True, - ) + path = "instance/service-accounts/{0}/".format(service_account) + # See https://cloud.google.com/compute/docs/metadata#aggcontents + # for more on the use of 'recursive'. + return get(request, path, params={"recursive": "true"}) def get_service_account_token(request, service_account="default"): diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index b7fca1832..8a41ffcc0 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -323,12 +323,9 @@ def _call_metadata_identity_endpoint(self, request): ValueError: If extracting expiry from the obtained ID token fails. """ try: - id_token = _metadata.get( - request, - "instance/service-accounts/default/identity?audience={}&format=full".format( - self._target_audience - ), - ) + path = "instance/service-accounts/default/identity" + params = {"audience": self._target_audience, "format": "full"} + id_token = _metadata.get(request, path, params=params) except exceptions.TransportError as caught_exc: new_exc = exceptions.RefreshError(caught_exc) six.raise_from(new_exc, caught_exc) diff --git a/tests/compute_engine/test__metadata.py b/tests/compute_engine/test__metadata.py index d9b039a32..d05337263 100644 --- a/tests/compute_engine/test__metadata.py +++ b/tests/compute_engine/test__metadata.py @@ -155,6 +155,49 @@ def test_get_success_text(): assert result == data +def test_get_success_params(): + data = "foobar" + request = make_request(data, headers={"content-type": "text/plain"}) + params = {"recursive": "true"} + + result = _metadata.get(request, PATH, params=params) + + request.assert_called_once_with( + method="GET", + url=_metadata._METADATA_ROOT + PATH + "?recursive=true", + headers=_metadata._METADATA_HEADERS, + ) + assert result == data + + +def test_get_success_recursive_and_params(): + data = "foobar" + request = make_request(data, headers={"content-type": "text/plain"}) + params = {"recursive": "false"} + result = _metadata.get(request, PATH, recursive=True, params=params) + + request.assert_called_once_with( + method="GET", + url=_metadata._METADATA_ROOT + PATH + "?recursive=true", + headers=_metadata._METADATA_HEADERS, + ) + assert result == data + + +def test_get_success_recursive(): + data = "foobar" + request = make_request(data, headers={"content-type": "text/plain"}) + + result = _metadata.get(request, PATH, recursive=True) + + request.assert_called_once_with( + method="GET", + url=_metadata._METADATA_ROOT + PATH + "?recursive=true", + headers=_metadata._METADATA_HEADERS, + ) + assert result == data + + def test_get_success_custom_root_new_variable(): request = make_request("{}", headers={"content-type": "application/json"}) From 05f95246fab928fe2f445781117eeac8088497fb Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Mon, 26 Oct 2020 20:52:11 -0400 Subject: [PATCH 16/35] fix: pin 'aoihttp < 3.7.0dev' (#634) Working around breaking change in 3.7.0. See: https://github.com/pnuckowski/aioresponses/issues/173 --- noxfile.py | 7 ++++++- setup.py | 2 +- system_tests/noxfile.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/noxfile.py b/noxfile.py index b92f4939d..388814491 100644 --- a/noxfile.py +++ b/noxfile.py @@ -30,7 +30,12 @@ "grpcio", ] -ASYNC_DEPENDENCIES = ["pytest-asyncio", "aioresponses", "asynctest"] +ASYNC_DEPENDENCIES = [ + "pytest-asyncio", + "aiohttp < 3.7.0dev", + "aioresponses", + "asynctest", +] BLACK_VERSION = "black==19.3b0" BLACK_PATHS = [ diff --git a/setup.py b/setup.py index 522b98103..16c277950 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ "six>=1.9.0", ) -extras = {"aiohttp": "aiohttp >= 3.6.2, < 4.0.0dev; python_version>='3.6'"} +extras = {"aiohttp": "aiohttp >= 3.6.2, < 3.7.0dev; python_version>='3.6'"} with io.open("README.rst", "r") as fh: long_description = fh.read() diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index a039228d9..0f852ea27 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -168,7 +168,7 @@ def configure_cloud_sdk(session, application_default_credentials, project=False) # Test sesssions -TEST_DEPENDENCIES_ASYNC = ["aiohttp", "pytest-asyncio", "nest-asyncio"] +TEST_DEPENDENCIES_ASYNC = ["aiohttp < 3.7.0dev", "pytest-asyncio", "nest-asyncio"] TEST_DEPENDENCIES_SYNC = ["pytest", "requests"] PYTHON_VERSIONS_ASYNC = ["3.7"] PYTHON_VERSIONS_SYNC = ["2.7", "3.7"] From 755e702fdaab718fe16b7ab9db228354df798cec Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Wed, 28 Oct 2020 12:47:31 -0700 Subject: [PATCH 17/35] chore: add infrastructure to support `docs-presubmit` build (via synth) (#578) * feat(python-library): changes to docs job * feat(python-library): changes to docs job * migrate to Trampoline V2 * add docs-presubmit job * create docfx yaml files and upload them to another bucket * remove redundant envvars * add a failing test first * fix TemplateSyntaxError: Missing end of comment tag * serving_path is not needed any more * use `raw` to make jinja happy Source-Author: Takashi Matsuo Source-Date: Thu Jul 30 12:44:02 2020 -0700 Source-Repo: googleapis/synthtool Source-Sha: 5dfda5621df45b71b6e88544ebbb53b1a8c90214 Source-Link: https://github.com/googleapis/synthtool/commit/5dfda5621df45b71b6e88544ebbb53b1a8c90214 * fix(python-library): add missing changes Source-Author: Takashi Matsuo Source-Date: Thu Jul 30 18:26:35 2020 -0700 Source-Repo: googleapis/synthtool Source-Sha: 39b527a39f5cd56d4882b3874fc08eed4756cebe Source-Link: https://github.com/googleapis/synthtool/commit/39b527a39f5cd56d4882b3874fc08eed4756cebe Co-authored-by: Tres Seaver --- .kokoro/docker/docs/Dockerfile | 98 ++++++ .kokoro/docker/docs/fetch_gpg_keys.sh | 45 +++ .kokoro/docs/common.cfg | 21 +- .kokoro/docs/docs-presubmit.cfg | 17 + .kokoro/publish-docs.sh | 39 ++- .kokoro/trampoline_v2.sh | 487 ++++++++++++++++++++++++++ synth.metadata | 4 +- 7 files changed, 692 insertions(+), 19 deletions(-) create mode 100644 .kokoro/docker/docs/Dockerfile create mode 100755 .kokoro/docker/docs/fetch_gpg_keys.sh create mode 100644 .kokoro/docs/docs-presubmit.cfg create mode 100755 .kokoro/trampoline_v2.sh diff --git a/.kokoro/docker/docs/Dockerfile b/.kokoro/docker/docs/Dockerfile new file mode 100644 index 000000000..412b0b56a --- /dev/null +++ b/.kokoro/docker/docs/Dockerfile @@ -0,0 +1,98 @@ +# Copyright 2020 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. + +from ubuntu:20.04 + +ENV DEBIAN_FRONTEND noninteractive + +# Ensure local Python is preferred over distribution Python. +ENV PATH /usr/local/bin:$PATH + +# Install dependencies. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + apt-transport-https \ + build-essential \ + ca-certificates \ + curl \ + dirmngr \ + git \ + gpg-agent \ + graphviz \ + libbz2-dev \ + libdb5.3-dev \ + libexpat1-dev \ + libffi-dev \ + liblzma-dev \ + libreadline-dev \ + libsnappy-dev \ + libssl-dev \ + libsqlite3-dev \ + portaudio19-dev \ + redis-server \ + software-properties-common \ + ssh \ + sudo \ + tcl \ + tcl-dev \ + tk \ + tk-dev \ + uuid-dev \ + wget \ + zlib1g-dev \ + && add-apt-repository universe \ + && apt-get update \ + && apt-get -y install jq \ + && apt-get clean autoclean \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /var/cache/apt/archives/*.deb + + +COPY fetch_gpg_keys.sh /tmp +# Install the desired versions of Python. +RUN set -ex \ + && export GNUPGHOME="$(mktemp -d)" \ + && echo "disable-ipv6" >> "${GNUPGHOME}/dirmngr.conf" \ + && /tmp/fetch_gpg_keys.sh \ + && for PYTHON_VERSION in 3.7.8 3.8.5; do \ + wget --no-check-certificate -O python-${PYTHON_VERSION}.tar.xz "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz" \ + && wget --no-check-certificate -O python-${PYTHON_VERSION}.tar.xz.asc "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc" \ + && gpg --batch --verify python-${PYTHON_VERSION}.tar.xz.asc python-${PYTHON_VERSION}.tar.xz \ + && rm -r python-${PYTHON_VERSION}.tar.xz.asc \ + && mkdir -p /usr/src/python-${PYTHON_VERSION} \ + && tar -xJC /usr/src/python-${PYTHON_VERSION} --strip-components=1 -f python-${PYTHON_VERSION}.tar.xz \ + && rm python-${PYTHON_VERSION}.tar.xz \ + && cd /usr/src/python-${PYTHON_VERSION} \ + && ./configure \ + --enable-shared \ + # This works only on Python 2.7 and throws a warning on every other + # version, but seems otherwise harmless. + --enable-unicode=ucs4 \ + --with-system-ffi \ + --without-ensurepip \ + && make -j$(nproc) \ + && make install \ + && ldconfig \ + ; done \ + && rm -rf "${GNUPGHOME}" \ + && rm -rf /usr/src/python* \ + && rm -rf ~/.cache/ + +RUN wget -O /tmp/get-pip.py 'https://bootstrap.pypa.io/get-pip.py' \ + && python3.7 /tmp/get-pip.py \ + && python3.8 /tmp/get-pip.py \ + && rm /tmp/get-pip.py + +CMD ["python3.7"] diff --git a/.kokoro/docker/docs/fetch_gpg_keys.sh b/.kokoro/docker/docs/fetch_gpg_keys.sh new file mode 100755 index 000000000..d653dd868 --- /dev/null +++ b/.kokoro/docker/docs/fetch_gpg_keys.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Copyright 2020 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. + +# A script to fetch gpg keys with retry. +# Avoid jinja parsing the file. +# + +function retry { + if [[ "${#}" -le 1 ]]; then + echo "Usage: ${0} retry_count commands.." + exit 1 + fi + local retries=${1} + local command="${@:2}" + until [[ "${retries}" -le 0 ]]; do + $command && return 0 + if [[ $? -ne 0 ]]; then + echo "command failed, retrying" + ((retries--)) + fi + done + return 1 +} + +# 3.6.9, 3.7.5 (Ned Deily) +retry 3 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys \ + 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D + +# 3.8.0 (Łukasz Langa) +retry 3 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys \ + E3FF2839C048B25C084DEBE9B26995E310250568 + +# diff --git a/.kokoro/docs/common.cfg b/.kokoro/docs/common.cfg index e49c23215..d6b496716 100644 --- a/.kokoro/docs/common.cfg +++ b/.kokoro/docs/common.cfg @@ -11,12 +11,12 @@ action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-python/.kokoro/trampoline.sh" +build_file: "google-auth-library-python/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/python-multi" + value: "gcr.io/cloud-devrel-kokoro-resources/python-lib-docs" } env_vars: { key: "TRAMPOLINE_BUILD_FILE" @@ -28,6 +28,23 @@ env_vars: { value: "docs-staging" } +env_vars: { + key: "V2_STAGING_BUCKET" + value: "docs-staging-v2-staging" +} + +# It will upload the docker image after successful builds. +env_vars: { + key: "TRAMPOLINE_IMAGE_UPLOAD" + value: "true" +} + +# It will always build the docker image. +env_vars: { + key: "TRAMPOLINE_DOCKERFILE" + value: ".kokoro/docker/docs/Dockerfile" +} + # Fetch the token needed for reporting release status to GitHub before_action { fetch_keystore { diff --git a/.kokoro/docs/docs-presubmit.cfg b/.kokoro/docs/docs-presubmit.cfg new file mode 100644 index 000000000..111810782 --- /dev/null +++ b/.kokoro/docs/docs-presubmit.cfg @@ -0,0 +1,17 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "STAGING_BUCKET" + value: "gcloud-python-test" +} + +env_vars: { + key: "V2_STAGING_BUCKET" + value: "gcloud-python-test" +} + +# We only upload the image in the main `docs` build. +env_vars: { + key: "TRAMPOLINE_IMAGE_UPLOAD" + value: "false" +} diff --git a/.kokoro/publish-docs.sh b/.kokoro/publish-docs.sh index 0e5d97867..8acb14e80 100755 --- a/.kokoro/publish-docs.sh +++ b/.kokoro/publish-docs.sh @@ -18,26 +18,16 @@ set -eo pipefail # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 -cd github/google-auth-library-python - -# Remove old nox -python3.6 -m pip uninstall --yes --quiet nox-automation +export PATH="${HOME}/.local/bin:${PATH}" # Install nox -python3.6 -m pip install --upgrade --quiet nox -python3.6 -m nox --version +python3 -m pip install --user --upgrade --quiet nox +python3 -m nox --version # build docs nox -s docs -python3 -m pip install gcp-docuploader - -# install a json parser -sudo apt-get update -sudo apt-get -y install software-properties-common -sudo add-apt-repository universe -sudo apt-get update -sudo apt-get -y install jq +python3 -m pip install --user gcp-docuploader # create metadata python3 -m docuploader create-metadata \ @@ -52,4 +42,23 @@ python3 -m docuploader create-metadata \ cat docs.metadata # upload docs -python3 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket docs-staging +python3 -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 \ + --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ + --version=$(python3 setup.py --version) \ + --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ + --distribution-name=$(python3 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) + +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}" diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh new file mode 100755 index 000000000..719bcd5ba --- /dev/null +++ b/.kokoro/trampoline_v2.sh @@ -0,0 +1,487 @@ +#!/usr/bin/env bash +# Copyright 2020 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. + +# trampoline_v2.sh +# +# This script does 3 things. +# +# 1. Prepare the Docker image for the test +# 2. Run the Docker with appropriate flags to run the test +# 3. Upload the newly built Docker image +# +# in a way that is somewhat compatible with trampoline_v1. +# +# To run this script, first download few files from gcs to /dev/shm. +# (/dev/shm is passed into the container as KOKORO_GFILE_DIR). +# +# gsutil cp gs://cloud-devrel-kokoro-resources/python-docs-samples/secrets_viewer_service_account.json /dev/shm +# gsutil cp gs://cloud-devrel-kokoro-resources/python-docs-samples/automl_secrets.txt /dev/shm +# +# Then run the script. +# .kokoro/trampoline_v2.sh +# +# These environment variables are required: +# TRAMPOLINE_IMAGE: The docker image to use. +# TRAMPOLINE_DOCKERFILE: The location of the Dockerfile. +# +# You can optionally change these environment variables: +# TRAMPOLINE_IMAGE_UPLOAD: +# (true|false): Whether to upload the Docker image after the +# successful builds. +# TRAMPOLINE_BUILD_FILE: The script to run in the docker container. +# TRAMPOLINE_WORKSPACE: The workspace path in the docker container. +# Defaults to /workspace. +# Potentially there are some repo specific envvars in .trampolinerc in +# the project root. + + +set -euo pipefail + +TRAMPOLINE_VERSION="2.0.5" + +if command -v tput >/dev/null && [[ -n "${TERM:-}" ]]; then + readonly IO_COLOR_RED="$(tput setaf 1)" + readonly IO_COLOR_GREEN="$(tput setaf 2)" + readonly IO_COLOR_YELLOW="$(tput setaf 3)" + readonly IO_COLOR_RESET="$(tput sgr0)" +else + readonly IO_COLOR_RED="" + readonly IO_COLOR_GREEN="" + readonly IO_COLOR_YELLOW="" + readonly IO_COLOR_RESET="" +fi + +function function_exists { + [ $(LC_ALL=C type -t $1)"" == "function" ] +} + +# Logs a message using the given color. The first argument must be one +# of the IO_COLOR_* variables defined above, such as +# "${IO_COLOR_YELLOW}". The remaining arguments will be logged in the +# given color. The log message will also have an RFC-3339 timestamp +# prepended (in UTC). You can disable the color output by setting +# TERM=vt100. +function log_impl() { + local color="$1" + shift + local timestamp="$(date -u "+%Y-%m-%dT%H:%M:%SZ")" + echo "================================================================" + echo "${color}${timestamp}:" "$@" "${IO_COLOR_RESET}" + echo "================================================================" +} + +# Logs the given message with normal coloring and a timestamp. +function log() { + log_impl "${IO_COLOR_RESET}" "$@" +} + +# Logs the given message in green with a timestamp. +function log_green() { + log_impl "${IO_COLOR_GREEN}" "$@" +} + +# Logs the given message in yellow with a timestamp. +function log_yellow() { + log_impl "${IO_COLOR_YELLOW}" "$@" +} + +# Logs the given message in red with a timestamp. +function log_red() { + log_impl "${IO_COLOR_RED}" "$@" +} + +readonly tmpdir=$(mktemp -d -t ci-XXXXXXXX) +readonly tmphome="${tmpdir}/h" +mkdir -p "${tmphome}" + +function cleanup() { + rm -rf "${tmpdir}" +} +trap cleanup EXIT + +RUNNING_IN_CI="${RUNNING_IN_CI:-false}" + +# The workspace in the container, defaults to /workspace. +TRAMPOLINE_WORKSPACE="${TRAMPOLINE_WORKSPACE:-/workspace}" + +pass_down_envvars=( + # TRAMPOLINE_V2 variables. + # Tells scripts whether they are running as part of CI or not. + "RUNNING_IN_CI" + # Indicates which CI system we're in. + "TRAMPOLINE_CI" + # Indicates the version of the script. + "TRAMPOLINE_VERSION" +) + +log_yellow "Building with Trampoline ${TRAMPOLINE_VERSION}" + +# Detect which CI systems we're in. If we're in any of the CI systems +# we support, `RUNNING_IN_CI` will be true and `TRAMPOLINE_CI` will be +# the name of the CI system. Both envvars will be passing down to the +# container for telling which CI system we're in. +if [[ -n "${KOKORO_BUILD_ID:-}" ]]; then + # descriptive env var for indicating it's on CI. + RUNNING_IN_CI="true" + TRAMPOLINE_CI="kokoro" + if [[ "${TRAMPOLINE_USE_LEGACY_SERVICE_ACCOUNT:-}" == "true" ]]; then + if [[ ! -f "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" ]]; then + log_red "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json does not exist. Did you forget to mount cloud-devrel-kokoro-resources/trampoline? Aborting." + exit 1 + fi + # This service account will be activated later. + TRAMPOLINE_SERVICE_ACCOUNT="${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" + else + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + gcloud auth list + fi + log_yellow "Configuring Container Registry access" + gcloud auth configure-docker --quiet + fi + pass_down_envvars+=( + # KOKORO dynamic variables. + "KOKORO_BUILD_NUMBER" + "KOKORO_BUILD_ID" + "KOKORO_JOB_NAME" + "KOKORO_GIT_COMMIT" + "KOKORO_GITHUB_COMMIT" + "KOKORO_GITHUB_PULL_REQUEST_NUMBER" + "KOKORO_GITHUB_PULL_REQUEST_COMMIT" + # For Build Cop Bot + "KOKORO_GITHUB_COMMIT_URL" + "KOKORO_GITHUB_PULL_REQUEST_URL" + ) +elif [[ "${TRAVIS:-}" == "true" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="travis" + pass_down_envvars+=( + "TRAVIS_BRANCH" + "TRAVIS_BUILD_ID" + "TRAVIS_BUILD_NUMBER" + "TRAVIS_BUILD_WEB_URL" + "TRAVIS_COMMIT" + "TRAVIS_COMMIT_MESSAGE" + "TRAVIS_COMMIT_RANGE" + "TRAVIS_JOB_NAME" + "TRAVIS_JOB_NUMBER" + "TRAVIS_JOB_WEB_URL" + "TRAVIS_PULL_REQUEST" + "TRAVIS_PULL_REQUEST_BRANCH" + "TRAVIS_PULL_REQUEST_SHA" + "TRAVIS_PULL_REQUEST_SLUG" + "TRAVIS_REPO_SLUG" + "TRAVIS_SECURE_ENV_VARS" + "TRAVIS_TAG" + ) +elif [[ -n "${GITHUB_RUN_ID:-}" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="github-workflow" + pass_down_envvars+=( + "GITHUB_WORKFLOW" + "GITHUB_RUN_ID" + "GITHUB_RUN_NUMBER" + "GITHUB_ACTION" + "GITHUB_ACTIONS" + "GITHUB_ACTOR" + "GITHUB_REPOSITORY" + "GITHUB_EVENT_NAME" + "GITHUB_EVENT_PATH" + "GITHUB_SHA" + "GITHUB_REF" + "GITHUB_HEAD_REF" + "GITHUB_BASE_REF" + ) +elif [[ "${CIRCLECI:-}" == "true" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="circleci" + pass_down_envvars+=( + "CIRCLE_BRANCH" + "CIRCLE_BUILD_NUM" + "CIRCLE_BUILD_URL" + "CIRCLE_COMPARE_URL" + "CIRCLE_JOB" + "CIRCLE_NODE_INDEX" + "CIRCLE_NODE_TOTAL" + "CIRCLE_PREVIOUS_BUILD_NUM" + "CIRCLE_PROJECT_REPONAME" + "CIRCLE_PROJECT_USERNAME" + "CIRCLE_REPOSITORY_URL" + "CIRCLE_SHA1" + "CIRCLE_STAGE" + "CIRCLE_USERNAME" + "CIRCLE_WORKFLOW_ID" + "CIRCLE_WORKFLOW_JOB_ID" + "CIRCLE_WORKFLOW_UPSTREAM_JOB_IDS" + "CIRCLE_WORKFLOW_WORKSPACE_ID" + ) +fi + +# Configure the service account for pulling the docker image. +function repo_root() { + local dir="$1" + while [[ ! -d "${dir}/.git" ]]; do + dir="$(dirname "$dir")" + done + echo "${dir}" +} + +# Detect the project root. In CI builds, we assume the script is in +# the git tree and traverse from there, otherwise, traverse from `pwd` +# to find `.git` directory. +if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + PROGRAM_PATH="$(realpath "$0")" + PROGRAM_DIR="$(dirname "${PROGRAM_PATH}")" + PROJECT_ROOT="$(repo_root "${PROGRAM_DIR}")" +else + PROJECT_ROOT="$(repo_root $(pwd))" +fi + +log_yellow "Changing to the project root: ${PROJECT_ROOT}." +cd "${PROJECT_ROOT}" + +# To support relative path for `TRAMPOLINE_SERVICE_ACCOUNT`, we need +# to use this environment variable in `PROJECT_ROOT`. +if [[ -n "${TRAMPOLINE_SERVICE_ACCOUNT:-}" ]]; then + + mkdir -p "${tmpdir}/gcloud" + gcloud_config_dir="${tmpdir}/gcloud" + + log_yellow "Using isolated gcloud config: ${gcloud_config_dir}." + export CLOUDSDK_CONFIG="${gcloud_config_dir}" + + log_yellow "Using ${TRAMPOLINE_SERVICE_ACCOUNT} for authentication." + gcloud auth activate-service-account \ + --key-file "${TRAMPOLINE_SERVICE_ACCOUNT}" + log_yellow "Configuring Container Registry access" + gcloud auth configure-docker --quiet +fi + +required_envvars=( + # The basic trampoline configurations. + "TRAMPOLINE_IMAGE" + "TRAMPOLINE_BUILD_FILE" +) + +if [[ -f "${PROJECT_ROOT}/.trampolinerc" ]]; then + source "${PROJECT_ROOT}/.trampolinerc" +fi + +log_yellow "Checking environment variables." +for e in "${required_envvars[@]}" +do + if [[ -z "${!e:-}" ]]; then + log "Missing ${e} env var. Aborting." + exit 1 + fi +done + +# We want to support legacy style TRAMPOLINE_BUILD_FILE used with V1 +# script: e.g. "github/repo-name/.kokoro/run_tests.sh" +TRAMPOLINE_BUILD_FILE="${TRAMPOLINE_BUILD_FILE#github/*/}" +log_yellow "Using TRAMPOLINE_BUILD_FILE: ${TRAMPOLINE_BUILD_FILE}" + +# ignore error on docker operations and test execution +set +e + +log_yellow "Preparing Docker image." +# We only download the docker image in CI builds. +if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + # Download the docker image specified by `TRAMPOLINE_IMAGE` + + # We may want to add --max-concurrent-downloads flag. + + log_yellow "Start pulling the Docker image: ${TRAMPOLINE_IMAGE}." + if docker pull "${TRAMPOLINE_IMAGE}"; then + log_green "Finished pulling the Docker image: ${TRAMPOLINE_IMAGE}." + has_image="true" + else + log_red "Failed pulling the Docker image: ${TRAMPOLINE_IMAGE}." + has_image="false" + fi +else + # For local run, check if we have the image. + if docker images "${TRAMPOLINE_IMAGE}:latest" | grep "${TRAMPOLINE_IMAGE}"; then + has_image="true" + else + has_image="false" + fi +fi + + +# The default user for a Docker container has uid 0 (root). To avoid +# creating root-owned files in the build directory we tell docker to +# use the current user ID. +user_uid="$(id -u)" +user_gid="$(id -g)" +user_name="$(id -un)" + +# To allow docker in docker, we add the user to the docker group in +# the host os. +docker_gid=$(cut -d: -f3 < <(getent group docker)) + +update_cache="false" +if [[ "${TRAMPOLINE_DOCKERFILE:-none}" != "none" ]]; then + # Build the Docker image from the source. + context_dir=$(dirname "${TRAMPOLINE_DOCKERFILE}") + docker_build_flags=( + "-f" "${TRAMPOLINE_DOCKERFILE}" + "-t" "${TRAMPOLINE_IMAGE}" + "--build-arg" "UID=${user_uid}" + "--build-arg" "USERNAME=${user_name}" + ) + if [[ "${has_image}" == "true" ]]; then + docker_build_flags+=("--cache-from" "${TRAMPOLINE_IMAGE}") + fi + + log_yellow "Start building the docker image." + if [[ "${TRAMPOLINE_VERBOSE:-false}" == "true" ]]; then + echo "docker build" "${docker_build_flags[@]}" "${context_dir}" + fi + + # ON CI systems, we want to suppress docker build logs, only + # output the logs when it fails. + if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + if docker build "${docker_build_flags[@]}" "${context_dir}" \ + > "${tmpdir}/docker_build.log" 2>&1; then + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + cat "${tmpdir}/docker_build.log" + fi + + log_green "Finished building the docker image." + update_cache="true" + else + log_red "Failed to build the Docker image, aborting." + log_yellow "Dumping the build logs:" + cat "${tmpdir}/docker_build.log" + exit 1 + fi + else + if docker build "${docker_build_flags[@]}" "${context_dir}"; then + log_green "Finished building the docker image." + update_cache="true" + else + log_red "Failed to build the Docker image, aborting." + exit 1 + fi + fi +else + if [[ "${has_image}" != "true" ]]; then + log_red "We do not have ${TRAMPOLINE_IMAGE} locally, aborting." + exit 1 + fi +fi + +# We use an array for the flags so they are easier to document. +docker_flags=( + # Remove the container after it exists. + "--rm" + + # Use the host network. + "--network=host" + + # Run in priviledged mode. We are not using docker for sandboxing or + # isolation, just for packaging our dev tools. + "--privileged" + + # Run the docker script with the user id. Because the docker image gets to + # write in ${PWD} you typically want this to be your user id. + # To allow docker in docker, we need to use docker gid on the host. + "--user" "${user_uid}:${docker_gid}" + + # Pass down the USER. + "--env" "USER=${user_name}" + + # Mount the project directory inside the Docker container. + "--volume" "${PROJECT_ROOT}:${TRAMPOLINE_WORKSPACE}" + "--workdir" "${TRAMPOLINE_WORKSPACE}" + "--env" "PROJECT_ROOT=${TRAMPOLINE_WORKSPACE}" + + # Mount the temporary home directory. + "--volume" "${tmphome}:/h" + "--env" "HOME=/h" + + # Allow docker in docker. + "--volume" "/var/run/docker.sock:/var/run/docker.sock" + + # Mount the /tmp so that docker in docker can mount the files + # there correctly. + "--volume" "/tmp:/tmp" + # Pass down the KOKORO_GFILE_DIR and KOKORO_KEYSTORE_DIR + # TODO(tmatsuo): This part is not portable. + "--env" "TRAMPOLINE_SECRET_DIR=/secrets" + "--volume" "${KOKORO_GFILE_DIR:-/dev/shm}:/secrets/gfile" + "--env" "KOKORO_GFILE_DIR=/secrets/gfile" + "--volume" "${KOKORO_KEYSTORE_DIR:-/dev/shm}:/secrets/keystore" + "--env" "KOKORO_KEYSTORE_DIR=/secrets/keystore" +) + +# Add an option for nicer output if the build gets a tty. +if [[ -t 0 ]]; then + docker_flags+=("-it") +fi + +# Passing down env vars +for e in "${pass_down_envvars[@]}" +do + if [[ -n "${!e:-}" ]]; then + docker_flags+=("--env" "${e}=${!e}") + fi +done + +# If arguments are given, all arguments will become the commands run +# in the container, otherwise run TRAMPOLINE_BUILD_FILE. +if [[ $# -ge 1 ]]; then + log_yellow "Running the given commands '" "${@:1}" "' in the container." + readonly commands=("${@:1}") + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" + fi + docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" +else + log_yellow "Running the tests in a Docker container." + docker_flags+=("--entrypoint=${TRAMPOLINE_BUILD_FILE}") + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" + fi + docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" +fi + + +test_retval=$? + +if [[ ${test_retval} -eq 0 ]]; then + log_green "Build finished with ${test_retval}" +else + log_red "Build finished with ${test_retval}" +fi + +# Only upload it when the test passes. +if [[ "${update_cache}" == "true" ]] && \ + [[ $test_retval == 0 ]] && \ + [[ "${TRAMPOLINE_IMAGE_UPLOAD:-false}" == "true" ]]; then + log_yellow "Uploading the Docker image." + if docker push "${TRAMPOLINE_IMAGE}"; then + log_green "Finished uploading the Docker image." + else + log_red "Failed uploading the Docker image." + fi + # Call trampoline_after_upload_hook if it's defined. + if function_exists trampoline_after_upload_hook; then + trampoline_after_upload_hook + fi + +fi + +exit "${test_retval}" diff --git a/synth.metadata b/synth.metadata index 901a2cb9b..2563871fc 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-python.git", - "sha": "218a159f646c81021c890b92f9cff003aed949a8" + "sha": "20f82e22b7e8c6c7fdd29e08eaf7b4cf2abdcf37" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "ffe10407ee2f261c799fb0d01bf32a8abc67ed1e" + "sha": "39b527a39f5cd56d4882b3874fc08eed4756cebe" } } ] From 9c4200dff31986b7ff300126e9aa35d14aa84dba Mon Sep 17 00:00:00 2001 From: matthewhughes934 <34972397+matthewhughes934@users.noreply.github.com> Date: Wed, 28 Oct 2020 20:00:02 +0000 Subject: [PATCH 18/35] Update example in oauth2.id_token docs (#624) Since c05b8b5 oauth2.id_token.verify_oauth2_token handles the issuer check itself, so remove this redundant check from the docs. --- google/oauth2/id_token.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/google/oauth2/id_token.py b/google/oauth2/id_token.py index bf6bf2c70..d70782b51 100644 --- a/google/oauth2/id_token.py +++ b/google/oauth2/id_token.py @@ -33,9 +33,6 @@ id_info = id_token.verify_oauth2_token( token, request, 'my-client-id.example.com') - if id_info['iss'] != 'https://accounts.google.com': - raise ValueError('Wrong issuer.') - userid = id_info['sub'] By default, this will re-fetch certificates for each verification. Because From d0a47c10ca0ef1712b415b9d9c34118ab8b6cfee Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Thu, 29 Oct 2020 07:58:21 -0700 Subject: [PATCH 19/35] build: use pypi secret from secret manager (#639) --- .kokoro/build.sh | 8 ++++- .kokoro/docs/common.cfg | 2 +- .kokoro/populate-secrets.sh | 43 ++++++++++++++++++++++++ .kokoro/release/common.cfg | 50 ++++++++-------------------- .kokoro/samples/python3.6/common.cfg | 6 ++++ .kokoro/samples/python3.7/common.cfg | 6 ++++ .kokoro/samples/python3.8/common.cfg | 6 ++++ .kokoro/test-samples.sh | 8 ++++- .kokoro/trampoline.sh | 15 ++++++--- synth.metadata | 40 ++++++++++++++++++++-- 10 files changed, 137 insertions(+), 47 deletions(-) create mode 100755 .kokoro/populate-secrets.sh diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 3ce87f39d..3a63e98c6 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -36,4 +36,10 @@ python3.6 -m pip uninstall --yes --quiet nox-automation python3.6 -m pip install --upgrade --quiet nox python3.6 -m nox --version -python3.6 -m nox +# If NOX_SESSION is set, it only runs the specified session, +# otherwise run all the sessions. +if [[ -n "${NOX_SESSION:-}" ]]; then + python3.6 -m nox -s "${NOX_SESSION:-}" +else + python3.6 -m nox +fi diff --git a/.kokoro/docs/common.cfg b/.kokoro/docs/common.cfg index d6b496716..24c8c89dd 100644 --- a/.kokoro/docs/common.cfg +++ b/.kokoro/docs/common.cfg @@ -30,7 +30,7 @@ env_vars: { env_vars: { key: "V2_STAGING_BUCKET" - value: "docs-staging-v2-staging" + value: "docs-staging-v2" } # It will upload the docker image after successful builds. diff --git a/.kokoro/populate-secrets.sh b/.kokoro/populate-secrets.sh new file mode 100755 index 000000000..f52514257 --- /dev/null +++ b/.kokoro/populate-secrets.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Copyright 2020 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. + +set -eo pipefail + +function now { date +"%Y-%m-%d %H:%M:%S" | tr -d '\n' ;} +function msg { println "$*" >&2 ;} +function println { printf '%s\n' "$(now) $*" ;} + + +# Populates requested secrets set in SECRET_MANAGER_KEYS from service account: +# kokoro-trampoline@cloud-devrel-kokoro-resources.iam.gserviceaccount.com +SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" +msg "Creating folder on disk for secrets: ${SECRET_LOCATION}" +mkdir -p ${SECRET_LOCATION} +for key in $(echo ${SECRET_MANAGER_KEYS} | sed "s/,/ /g") +do + msg "Retrieving secret ${key}" + docker run --entrypoint=gcloud \ + --volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR} \ + gcr.io/google.com/cloudsdktool/cloud-sdk \ + secrets versions access latest \ + --project cloud-devrel-kokoro-resources \ + --secret ${key} > \ + "${SECRET_LOCATION}/${key}" + if [[ $? == 0 ]]; then + msg "Secret written to ${SECRET_LOCATION}/${key}" + else + msg "Error retrieving secret ${key}" + fi +done diff --git a/.kokoro/release/common.cfg b/.kokoro/release/common.cfg index b2088d009..b56ca902d 100644 --- a/.kokoro/release/common.cfg +++ b/.kokoro/release/common.cfg @@ -23,42 +23,18 @@ env_vars: { value: "github/google-auth-library-python/.kokoro/release.sh" } -# Fetch the token needed for reporting release status to GitHub -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "yoshi-automation-github-key" - } - } -} - -# Fetch PyPI password -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "google_cloud_pypi_password" - } - } -} - -# Fetch magictoken to use with Magic Github Proxy -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "releasetool-magictoken" - } - } +# Fetch PyPI password +before_action { + fetch_keystore { + keystore_resource { + keystore_config_id: 73713 + keyname: "google_cloud_pypi_password" + } + } } -# Fetch api key to use with Magic Github Proxy -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "magic-github-proxy-api-key" - } - } -} +# Tokens needed to report release status back to GitHub +env_vars: { + key: "SECRET_MANAGER_KEYS" + value: "releasetool-publish-reporter-app,releasetool-publish-reporter-googleapis-installation,releasetool-publish-reporter-pem" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.6/common.cfg b/.kokoro/samples/python3.6/common.cfg index 792bc4bbe..4895c2bcf 100644 --- a/.kokoro/samples/python3.6/common.cfg +++ b/.kokoro/samples/python3.6/common.cfg @@ -13,6 +13,12 @@ env_vars: { value: "py-3.6" } +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py36" +} + env_vars: { key: "TRAMPOLINE_BUILD_FILE" value: "github/google-auth-library-python/.kokoro/test-samples.sh" diff --git a/.kokoro/samples/python3.7/common.cfg b/.kokoro/samples/python3.7/common.cfg index 209f6cef9..90aaef1b4 100644 --- a/.kokoro/samples/python3.7/common.cfg +++ b/.kokoro/samples/python3.7/common.cfg @@ -13,6 +13,12 @@ env_vars: { value: "py-3.7" } +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py37" +} + env_vars: { key: "TRAMPOLINE_BUILD_FILE" value: "github/google-auth-library-python/.kokoro/test-samples.sh" diff --git a/.kokoro/samples/python3.8/common.cfg b/.kokoro/samples/python3.8/common.cfg index b0095dabd..78fd8c749 100644 --- a/.kokoro/samples/python3.8/common.cfg +++ b/.kokoro/samples/python3.8/common.cfg @@ -13,6 +13,12 @@ env_vars: { value: "py-3.8" } +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py38" +} + env_vars: { key: "TRAMPOLINE_BUILD_FILE" value: "github/google-auth-library-python/.kokoro/test-samples.sh" diff --git a/.kokoro/test-samples.sh b/.kokoro/test-samples.sh index f4426f67a..9a9de2086 100755 --- a/.kokoro/test-samples.sh +++ b/.kokoro/test-samples.sh @@ -28,6 +28,12 @@ if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then git checkout $LATEST_RELEASE fi +# Exit early if samples directory doesn't exist +if [ ! -d "./samples" ]; then + echo "No tests run. `./samples` not found" + exit 0 +fi + # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 @@ -101,4 +107,4 @@ cd "$ROOT" # Workaround for Kokoro permissions issue: delete secrets rm testing/{test-env.sh,client-secrets.json,service-account.json} -exit "$RTN" \ No newline at end of file +exit "$RTN" diff --git a/.kokoro/trampoline.sh b/.kokoro/trampoline.sh index e8c4251f3..f39236e94 100755 --- a/.kokoro/trampoline.sh +++ b/.kokoro/trampoline.sh @@ -15,9 +15,14 @@ set -eo pipefail -python3 "${KOKORO_GFILE_DIR}/trampoline_v1.py" || ret_code=$? +# Always run the cleanup script, regardless of the success of bouncing into +# the container. +function cleanup() { + chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh + ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh + echo "cleanup"; +} +trap cleanup EXIT -chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh -${KOKORO_GFILE_DIR}/trampoline_cleanup.sh || true - -exit ${ret_code} +$(dirname $0)/populate-secrets.sh # Secret Manager secrets. +python3 "${KOKORO_GFILE_DIR}/trampoline_v1.py" \ No newline at end of file diff --git a/synth.metadata b/synth.metadata index 2563871fc..5e1ef9a55 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,15 +4,51 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-python.git", - "sha": "20f82e22b7e8c6c7fdd29e08eaf7b4cf2abdcf37" + "sha": "9c4200dff31986b7ff300126e9aa35d14aa84dba" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "39b527a39f5cd56d4882b3874fc08eed4756cebe" + "sha": "da5c6050d13b4950c82666a81d8acd25157664ae" } } + ], + "generatedFiles": [ + ".kokoro/build.sh", + ".kokoro/continuous/common.cfg", + ".kokoro/continuous/continuous.cfg", + ".kokoro/docker/docs/Dockerfile", + ".kokoro/docker/docs/fetch_gpg_keys.sh", + ".kokoro/docs/common.cfg", + ".kokoro/docs/docs-presubmit.cfg", + ".kokoro/docs/docs.cfg", + ".kokoro/populate-secrets.sh", + ".kokoro/presubmit/common.cfg", + ".kokoro/presubmit/presubmit.cfg", + ".kokoro/publish-docs.sh", + ".kokoro/release.sh", + ".kokoro/release/common.cfg", + ".kokoro/release/release.cfg", + ".kokoro/samples/lint/common.cfg", + ".kokoro/samples/lint/continuous.cfg", + ".kokoro/samples/lint/periodic.cfg", + ".kokoro/samples/lint/presubmit.cfg", + ".kokoro/samples/python3.6/common.cfg", + ".kokoro/samples/python3.6/continuous.cfg", + ".kokoro/samples/python3.6/periodic.cfg", + ".kokoro/samples/python3.6/presubmit.cfg", + ".kokoro/samples/python3.7/common.cfg", + ".kokoro/samples/python3.7/continuous.cfg", + ".kokoro/samples/python3.7/periodic.cfg", + ".kokoro/samples/python3.7/presubmit.cfg", + ".kokoro/samples/python3.8/common.cfg", + ".kokoro/samples/python3.8/continuous.cfg", + ".kokoro/samples/python3.8/periodic.cfg", + ".kokoro/samples/python3.8/presubmit.cfg", + ".kokoro/test-samples.sh", + ".kokoro/trampoline.sh", + ".kokoro/trampoline_v2.sh" ] } \ No newline at end of file From 0323cf390b16e8483660ac88775e8ea4e7f7702d Mon Sep 17 00:00:00 2001 From: David Buxton Date: Thu, 29 Oct 2020 21:26:11 +0000 Subject: [PATCH 20/35] feat: Add custom scopes for access tokens from the metadata service (#633) This works for App Engine, Cloud Run and Flex. On Compute Engine you can request custom scopes, but they are ignored. Co-authored-by: Tres Seaver Co-authored-by: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> --- google/auth/_default.py | 10 +++-- google/auth/_default_async.py | 10 +++-- google/auth/compute_engine/_metadata.py | 17 +++++--- google/auth/compute_engine/credentials.py | 44 ++++++++++++------- system_tests/noxfile.py | 2 +- tests/compute_engine/test_credentials.py | 51 ++++++++++++++++++++++- 6 files changed, 104 insertions(+), 30 deletions(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index de81c5b2c..43778931a 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -274,10 +274,11 @@ def default(scopes=None, request=None, quota_project_id=None): gcloud config set project 3. If the application is running in the `App Engine standard environment`_ - then the credentials and project ID from the `App Identity Service`_ - are used. - 4. If the application is running in `Compute Engine`_ or the - `App Engine flexible environment`_ then the credentials and project ID + (first generation) then the credentials and project ID from the + `App Identity Service`_ are used. + 4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or + the `App Engine flexible environment`_ or the `App Engine standard + environment`_ (second generation) then the credentials and project ID are obtained from the `Metadata Service`_. 5. If no credentials are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. @@ -293,6 +294,7 @@ def default(scopes=None, request=None, quota_project_id=None): /appengine/flexible .. _Metadata Service: https://cloud.google.com/compute/docs\ /storing-retrieving-metadata + .. _Cloud Run: https://cloud.google.com/run Example:: diff --git a/google/auth/_default_async.py b/google/auth/_default_async.py index 3347fbfdc..1a725afba 100644 --- a/google/auth/_default_async.py +++ b/google/auth/_default_async.py @@ -187,10 +187,11 @@ def default_async(scopes=None, request=None, quota_project_id=None): gcloud config set project 3. If the application is running in the `App Engine standard environment`_ - then the credentials and project ID from the `App Identity Service`_ - are used. - 4. If the application is running in `Compute Engine`_ or the - `App Engine flexible environment`_ then the credentials and project ID + (first generation) then the credentials and project ID from the + `App Identity Service`_ are used. + 4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or + the `App Engine flexible environment`_ or the `App Engine standard + environment`_ (second generation) then the credentials and project ID are obtained from the `Metadata Service`_. 5. If no credentials are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. @@ -206,6 +207,7 @@ def default_async(scopes=None, request=None, quota_project_id=None): /appengine/flexible .. _Metadata Service: https://cloud.google.com/compute/docs\ /storing-retrieving-metadata + .. _Cloud Run: https://cloud.google.com/run Example:: diff --git a/google/auth/compute_engine/_metadata.py b/google/auth/compute_engine/_metadata.py index 94e4ffbf0..5687a42f9 100644 --- a/google/auth/compute_engine/_metadata.py +++ b/google/auth/compute_engine/_metadata.py @@ -234,7 +234,7 @@ def get_service_account_info(request, service_account="default"): return get(request, path, params={"recursive": "true"}) -def get_service_account_token(request, service_account="default"): +def get_service_account_token(request, service_account="default", scopes=None): """Get the OAuth 2.0 access token for a service account. Args: @@ -243,7 +243,8 @@ def get_service_account_token(request, service_account="default"): service_account (str): The string 'default' or a service account email address. The determines which service account for which to acquire an access token. - + scopes (Optional[Union[str, List[str]]]): Optional string or list of + strings with auth scopes. Returns: Union[str, datetime]: The access token and its expiration. @@ -251,9 +252,15 @@ def get_service_account_token(request, service_account="default"): google.auth.exceptions.TransportError: if an error occurred while retrieving metadata. """ - token_json = get( - request, "instance/service-accounts/{0}/token".format(service_account) - ) + if scopes: + if not isinstance(scopes, str): + scopes = ",".join(scopes) + params = {"scopes": scopes} + else: + params = None + + path = "instance/service-accounts/{0}/token".format(service_account) + token_json = get(request, path, params=params) token_expiry = _helpers.utcnow() + datetime.timedelta( seconds=token_json["expires_in"] ) diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 8a41ffcc0..4ac6c8c2c 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -32,29 +32,28 @@ from google.oauth2 import _client -class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject): +class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject): """Compute Engine Credentials. These credentials use the Google Compute Engine metadata server to obtain - OAuth 2.0 access tokens associated with the instance's service account. + OAuth 2.0 access tokens associated with the instance's service account, + and are also used for Cloud Run, Flex and App Engine (except for the Python + 2.7 runtime). For more information about Compute Engine authentication, including how to configure scopes, see the `Compute Engine authentication documentation`_. - .. note:: Compute Engine instances can be created with scopes and therefore - these credentials are considered to be 'scoped'. However, you can - not use :meth:`~google.auth.credentials.ScopedCredentials.with_scopes` - because it is not possible to change the scopes that the instance - has. Also note that - :meth:`~google.auth.credentials.ScopedCredentials.has_scopes` will not - work until the credentials have been refreshed. + .. note:: On Compute Engine the metadata server ignores requested scopes. + On Cloud Run, Flex and App Engine the server honours requested scopes. .. _Compute Engine authentication documentation: https://cloud.google.com/compute/docs/authentication#using """ - def __init__(self, service_account_email="default", quota_project_id=None): + def __init__( + self, service_account_email="default", quota_project_id=None, scopes=None + ): """ Args: service_account_email (str): The service account email to use, or @@ -66,6 +65,7 @@ def __init__(self, service_account_email="default", quota_project_id=None): super(Credentials, self).__init__() self._service_account_email = service_account_email self._quota_project_id = quota_project_id + self._scopes = scopes def _retrieve_info(self, request): """Retrieve information about the service account. @@ -81,7 +81,10 @@ def _retrieve_info(self, request): ) self._service_account_email = info["email"] - self._scopes = info["scopes"] + + # Don't override scopes requested by the user. + if self._scopes is None: + self._scopes = info["scopes"] def refresh(self, request): """Refresh the access token and scopes. @@ -98,7 +101,9 @@ def refresh(self, request): try: self._retrieve_info(request) self.token, self.expiry = _metadata.get_service_account_token( - request, service_account=self._service_account_email + request, + service_account=self._service_account_email, + scopes=self._scopes, ) except exceptions.TransportError as caught_exc: new_exc = exceptions.RefreshError(caught_exc) @@ -115,14 +120,25 @@ def service_account_email(self): @property def requires_scopes(self): - """False: Compute Engine credentials can not be scoped.""" - return False + return not self._scopes @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( service_account_email=self._service_account_email, quota_project_id=quota_project_id, + scopes=self._scopes, + ) + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes): + # Compute Engine credentials can not be scoped (the metadata service + # ignores the scopes parameter). App Engine, Cloud Run and Flex support + # requesting scopes. + return self.__class__( + scopes=scopes, + service_account_email=self._service_account_email, + quota_project_id=self._quota_project_id, ) diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index 0f852ea27..699a1b3af 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -315,7 +315,7 @@ def default_explicit_service_account_async(session): session.env[EXPECT_PROJECT_ENV] = "1" session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) session.install(LIBRARY_DIR) - session.run("pytest", "system_tests_async/test_default.py", + session.run("pytest", "system_tests_async/test_default.py", "system_tests_async/test_id_token.py") diff --git a/tests/compute_engine/test_credentials.py b/tests/compute_engine/test_credentials.py index 4ee653676..ebe9aa5ba 100644 --- a/tests/compute_engine/test_credentials.py +++ b/tests/compute_engine/test_credentials.py @@ -55,8 +55,8 @@ def test_default_state(self): assert not self.credentials.valid # Expiration hasn't been set yet assert not self.credentials.expired - # Scopes aren't needed - assert not self.credentials.requires_scopes + # Scopes are needed + assert self.credentials.requires_scopes # Service account email hasn't been populated assert self.credentials.service_account_email == "default" # No quota project @@ -96,6 +96,45 @@ def test_refresh_success(self, get, utcnow): # expired) assert self.credentials.valid + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_success_with_scopes(self, get, utcnow): + get.side_effect = [ + { + # First request is for sevice account info. + "email": "service-account@example.com", + "scopes": ["one", "two"], + }, + { + # Second request is for the token. + "access_token": "token", + "expires_in": 500, + }, + ] + + # Refresh credentials + scopes = ["three", "four"] + self.credentials = self.credentials.with_scopes(scopes) + self.credentials.refresh(None) + + # Check that the credentials have the token and proper expiration + assert self.credentials.token == "token" + assert self.credentials.expiry == (utcnow() + datetime.timedelta(seconds=500)) + + # Check the credential info + assert self.credentials.service_account_email == "service-account@example.com" + assert self.credentials._scopes == scopes + + # Check that the credentials are valid (have a token and are not + # expired) + assert self.credentials.valid + + kwargs = get.call_args[1] + assert kwargs == {"params": {"scopes": "three,four"}} + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) def test_refresh_error(self, get): get.side_effect = exceptions.TransportError("http error") @@ -138,6 +177,14 @@ def test_with_quota_project(self): assert quota_project_creds._quota_project_id == "project-foo" + def test_with_scopes(self): + assert self.credentials._scopes is None + + scopes = ["one", "two"] + self.credentials = self.credentials.with_scopes(scopes) + + assert self.credentials._scopes == scopes + class TestIDTokenCredentials(object): credentials = None From b790e6535cc37591b23866027a426cde312e07c1 Mon Sep 17 00:00:00 2001 From: David Buxton Date: Thu, 29 Oct 2020 21:38:01 +0000 Subject: [PATCH 21/35] fix(deps): Revert "fix: pin 'aoihttp < 3.7.0dev' (#634)" (#632) (#640) This reverts commit 05f95246fab928fe2f445781117eeac8088497fb. The compatibility bug was fixed in the aioresponses package version 0.7.1 - https://pypi.org/project/aioresponses/ Fixes #632 --- noxfile.py | 7 +------ setup.py | 2 +- system_tests/noxfile.py | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/noxfile.py b/noxfile.py index 388814491..b92f4939d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -30,12 +30,7 @@ "grpcio", ] -ASYNC_DEPENDENCIES = [ - "pytest-asyncio", - "aiohttp < 3.7.0dev", - "aioresponses", - "asynctest", -] +ASYNC_DEPENDENCIES = ["pytest-asyncio", "aioresponses", "asynctest"] BLACK_VERSION = "black==19.3b0" BLACK_PATHS = [ diff --git a/setup.py b/setup.py index 16c277950..522b98103 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ "six>=1.9.0", ) -extras = {"aiohttp": "aiohttp >= 3.6.2, < 3.7.0dev; python_version>='3.6'"} +extras = {"aiohttp": "aiohttp >= 3.6.2, < 4.0.0dev; python_version>='3.6'"} with io.open("README.rst", "r") as fh: long_description = fh.read() diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index 699a1b3af..dcfe8ee81 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -168,7 +168,7 @@ def configure_cloud_sdk(session, application_default_credentials, project=False) # Test sesssions -TEST_DEPENDENCIES_ASYNC = ["aiohttp < 3.7.0dev", "pytest-asyncio", "nest-asyncio"] +TEST_DEPENDENCIES_ASYNC = ["aiohttp", "pytest-asyncio", "nest-asyncio"] TEST_DEPENDENCIES_SYNC = ["pytest", "requests"] PYTHON_VERSIONS_ASYNC = ["3.7"] PYTHON_VERSIONS_SYNC = ["2.7", "3.7"] From bc92abbce5e0a5e7b5de8157e7772db84349a6cb Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 21:48:02 +0000 Subject: [PATCH 22/35] chore: release 1.23.0 (#641) :robot: I have created a release \*beep\* \*boop\* --- ## [1.23.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.22.1...v1.23.0) (2020-10-29) ### Features * Add custom scopes for access tokens from the metadata service ([#633](https://www.github.com/googleapis/google-auth-library-python/issues/633)) ([0323cf3](https://www.github.com/googleapis/google-auth-library-python/commit/0323cf390b16e8483660ac88775e8ea4e7f7702d)) ### Bug Fixes * **deps:** Revert "fix: pin 'aoihttp < 3.7.0dev' ([#634](https://www.github.com/googleapis/google-auth-library-python/issues/634))" ([#632](https://www.github.com/googleapis/google-auth-library-python/issues/632)) ([#640](https://www.github.com/googleapis/google-auth-library-python/issues/640)) ([b790e65](https://www.github.com/googleapis/google-auth-library-python/commit/b790e6535cc37591b23866027a426cde312e07c1)) * pin 'aoihttp < 3.7.0dev' ([#634](https://www.github.com/googleapis/google-auth-library-python/issues/634)) ([05f9524](https://www.github.com/googleapis/google-auth-library-python/commit/05f95246fab928fe2f445781117eeac8088497fb)) * remove checks for ancient versions of Cryptography ([#596](https://www.github.com/googleapis/google-auth-library-python/issues/596)) ([6407258](https://www.github.com/googleapis/google-auth-library-python/commit/6407258956ec42e3b722418cb7f366e5ae9272ec)), closes [/github.com/googleapis/google-auth-library-python/issues/595#issuecomment-683903062](https://www.github.com/googleapis//github.com/googleapis/google-auth-library-python/issues/595/issues/issuecomment-683903062) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). --- CHANGELOG.md | 14 ++++++++++++++ setup.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d05e4e7d5..1e5950234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ [1]: https://pypi.org/project/google-auth/#history +## [1.23.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.22.1...v1.23.0) (2020-10-29) + + +### Features + +* Add custom scopes for access tokens from the metadata service ([#633](https://www.github.com/googleapis/google-auth-library-python/issues/633)) ([0323cf3](https://www.github.com/googleapis/google-auth-library-python/commit/0323cf390b16e8483660ac88775e8ea4e7f7702d)) + + +### Bug Fixes + +* **deps:** Revert "fix: pin 'aoihttp < 3.7.0dev' ([#634](https://www.github.com/googleapis/google-auth-library-python/issues/634))" ([#632](https://www.github.com/googleapis/google-auth-library-python/issues/632)) ([#640](https://www.github.com/googleapis/google-auth-library-python/issues/640)) ([b790e65](https://www.github.com/googleapis/google-auth-library-python/commit/b790e6535cc37591b23866027a426cde312e07c1)) +* pin 'aoihttp < 3.7.0dev' ([#634](https://www.github.com/googleapis/google-auth-library-python/issues/634)) ([05f9524](https://www.github.com/googleapis/google-auth-library-python/commit/05f95246fab928fe2f445781117eeac8088497fb)) +* remove checks for ancient versions of Cryptography ([#596](https://www.github.com/googleapis/google-auth-library-python/issues/596)) ([6407258](https://www.github.com/googleapis/google-auth-library-python/commit/6407258956ec42e3b722418cb7f366e5ae9272ec)), closes [/github.com/googleapis/google-auth-library-python/issues/595#issuecomment-683903062](https://www.github.com/googleapis//github.com/googleapis/google-auth-library-python/issues/595/issues/issuecomment-683903062) + ### [1.22.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.22.0...v1.22.1) (2020-10-05) diff --git a/setup.py b/setup.py index 522b98103..d599ecc17 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.22.1" +version = "1.23.0" setup( name="google-auth", From 3319ea8ae876c73a94f51237b3bbb3f5df2aef89 Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Mon, 7 Dec 2020 11:00:03 -0700 Subject: [PATCH 23/35] docs: fix typo in import (#651) `service_acccount` -> `service_account`. Closes #650 --- google/auth/impersonated_credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index d2c5ded1c..d96c05bbf 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -148,7 +148,7 @@ class Credentials(credentials.CredentialsWithQuotaProject, credentials.Signing): Initialize a source credential which does not have access to list bucket:: - from google.oauth2 import service_acccount + from google.oauth2 import service_account target_scopes = [ 'https://www.googleapis.com/auth/devstorage.read_only'] From 2d3b8d1d27fbab385cd10f81f5ca26ec5fc9d94f Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Tue, 8 Dec 2020 14:31:41 -0700 Subject: [PATCH 24/35] chore: fix comment about clock_skew (#653) Clock skew was changed in #581 --- google/auth/credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/credentials.py b/google/auth/credentials.py index bc42546b9..02082cad9 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -63,7 +63,7 @@ def expired(self): if not self.expiry: return False - # Remove 5 minutes from expiry to err on the side of reporting + # Remove 10 seconds from expiry to err on the side of reporting # expiration early so that we avoid the 401-refresh-retry loop. skewed_expiry = self.expiry - _helpers.CLOCK_SKEW return _helpers.utcnow() >= skewed_expiry From 6de753d585254c813b3e6cbde27bf5466261ba10 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Fri, 11 Dec 2020 13:20:39 -0500 Subject: [PATCH 25/35] feat: add Python 3.9 support, drop Python 3.5 support (#655) Closes #654. --- noxfile.py | 4 ++-- setup.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/noxfile.py b/noxfile.py index b92f4939d..adce2527c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -75,7 +75,7 @@ def blacken(session): session.run("black", *BLACK_PATHS) -@nox.session(python=["3.6", "3.7", "3.8"]) +@nox.session(python=["3.6", "3.7", "3.8", "3.9"]) def unit(session): session.install(*TEST_DEPENDENCIES) session.install(*(ASYNC_DEPENDENCIES)) @@ -90,7 +90,7 @@ def unit(session): ) -@nox.session(python=["2.7", "3.5"]) +@nox.session(python=["2.7"]) def unit_prev_versions(session): session.install(*TEST_DEPENDENCIES) session.install(".") diff --git a/setup.py b/setup.py index d599ecc17..66e74ee43 100644 --- a/setup.py +++ b/setup.py @@ -23,8 +23,8 @@ "pyasn1-modules>=0.2.1", # rsa==4.5 is the last version to support 2.7 # https://github.com/sybrenstuvel/python-rsa/issues/152#issuecomment-643470233 - 'rsa<4.6; python_version < "3.5"', - 'rsa>=3.1.4,<5; python_version >= "3.5"', + 'rsa<4.6; python_version < "3.6"', + 'rsa>=3.1.4,<5; python_version >= "3.6"', "setuptools>=40.3.0", "six>=1.9.0", ) @@ -48,17 +48,17 @@ namespace_packages=("google",), install_requires=DEPENDENCIES, extras_require=extras, - python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*", + python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*", license="Apache 2.0", keywords="google auth oauth client", classifiers=[ "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", From da922f096216873f5026199a222a5d7914bf32c5 Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Fri, 11 Dec 2020 11:34:04 -0700 Subject: [PATCH 26/35] chore: add constraints file (#649) Add constraints file to test lower bounds --- testing/constraints-3.10.txt | 0 testing/constraints-3.11.txt | 0 testing/constraints-3.6.txt | 14 ++++++++++++++ testing/constraints-3.7.txt | 0 testing/constraints-3.8.txt | 0 testing/constraints-3.9.txt | 0 6 files changed, 14 insertions(+) create mode 100644 testing/constraints-3.10.txt create mode 100644 testing/constraints-3.11.txt create mode 100644 testing/constraints-3.6.txt create mode 100644 testing/constraints-3.7.txt create mode 100644 testing/constraints-3.8.txt create mode 100644 testing/constraints-3.9.txt diff --git a/testing/constraints-3.10.txt b/testing/constraints-3.10.txt new file mode 100644 index 000000000..e69de29bb diff --git a/testing/constraints-3.11.txt b/testing/constraints-3.11.txt new file mode 100644 index 000000000..e69de29bb diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt new file mode 100644 index 000000000..ff7f099d4 --- /dev/null +++ b/testing/constraints-3.6.txt @@ -0,0 +1,14 @@ +# This constraints file is used to check that lower bounds +# are correct in setup.py +# List *all* library dependencies and extras in this file. +# Pin the version to the lower bound. +# +# e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", +# Then this file should have foo==1.14.0 +cachetools==2.0.0 +pyasn1-modules==0.2.1 +setuptools==40.3.0 +six==1.9.0 +rsa==4.6 +rsa==3.1.4 +aiohttp==3.6.2 \ No newline at end of file diff --git a/testing/constraints-3.7.txt b/testing/constraints-3.7.txt new file mode 100644 index 000000000..e69de29bb diff --git a/testing/constraints-3.8.txt b/testing/constraints-3.8.txt new file mode 100644 index 000000000..e69de29bb diff --git a/testing/constraints-3.9.txt b/testing/constraints-3.9.txt new file mode 100644 index 000000000..e69de29bb From ec1b688ac3ff5b65067ee02b8e30c1d15ba612c5 Mon Sep 17 00:00:00 2001 From: Daniel Gorelik Date: Fri, 11 Dec 2020 13:46:06 -0500 Subject: [PATCH 27/35] chore: fix typo (#647) --- google/auth/compute_engine/credentials.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 4ac6c8c2c..29063103a 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -14,8 +14,8 @@ """Google Compute Engine credentials. -This module provides authentication for application running on Google Compute -Engine using the Compute Engine metadata server. +This module provides authentication for an application running on Google +Compute Engine using the Compute Engine metadata server. """ From fd9b5b10c80950784bd37ee56e32c505acb5078d Mon Sep 17 00:00:00 2001 From: Pietro De Nicolao Date: Fri, 11 Dec 2020 20:00:30 +0100 Subject: [PATCH 28/35] fix: avoid losing the original '_include_email' parameter in impersonated credentials (#626) Co-authored-by: Tres Seaver --- google/auth/impersonated_credentials.py | 2 ++ tests/test_impersonated_credentials.py | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index d96c05bbf..4d158373a 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -341,6 +341,7 @@ def from_credentials(self, target_credentials, target_audience=None): return self.__class__( target_credentials=self._target_credentials, target_audience=target_audience, + include_email=self._include_email, quota_project_id=self._quota_project_id, ) @@ -348,6 +349,7 @@ def with_target_audience(self, target_audience): return self.__class__( target_credentials=self._target_credentials, target_audience=target_audience, + include_email=self._include_email, quota_project_id=self._quota_project_id, ) diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 46850a0d9..305f93926 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -368,12 +368,13 @@ def test_id_token_from_credential( assert not credentials.expired id_creds = impersonated_credentials.IDTokenCredentials( - credentials, target_audience=target_audience + credentials, target_audience=target_audience, include_email=True ) id_creds = id_creds.from_credentials(target_credentials=credentials) id_creds.refresh(request) assert id_creds.token == ID_TOKEN_DATA + assert id_creds._include_email is True def test_id_token_with_target_audience( self, mock_donor_credentials, mock_authorizedsession_idtoken @@ -396,12 +397,15 @@ def test_id_token_with_target_audience( assert credentials.valid assert not credentials.expired - id_creds = impersonated_credentials.IDTokenCredentials(credentials) + id_creds = impersonated_credentials.IDTokenCredentials( + credentials, include_email=True + ) id_creds = id_creds.with_target_audience(target_audience=target_audience) id_creds.refresh(request) assert id_creds.token == ID_TOKEN_DATA assert id_creds.expiry == datetime.datetime.fromtimestamp(ID_TOKEN_EXPIRY) + assert id_creds._include_email is True def test_id_token_invalid_cred( self, mock_donor_credentials, mock_authorizedsession_idtoken From 647290a2dfc797067f7966c1dae512359e6bb7e7 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 11 Dec 2020 19:12:02 +0000 Subject: [PATCH 29/35] chore: release 1.24.0 (#656) :robot: I have created a release \*beep\* \*boop\* --- ## [1.24.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.23.0...v1.24.0) (2020-12-11) ### Features * add Python 3.9 support, drop Python 3.5 support ([#655](https://www.github.com/googleapis/google-auth-library-python/issues/655)) ([6de753d](https://www.github.com/googleapis/google-auth-library-python/commit/6de753d585254c813b3e6cbde27bf5466261ba10)), closes [#654](https://www.github.com/googleapis/google-auth-library-python/issues/654) ### Bug Fixes * avoid losing the original '_include_email' parameter in impersonated credentials ([#626](https://www.github.com/googleapis/google-auth-library-python/issues/626)) ([fd9b5b1](https://www.github.com/googleapis/google-auth-library-python/commit/fd9b5b10c80950784bd37ee56e32c505acb5078d)) ### Documentation * fix typo in import ([#651](https://www.github.com/googleapis/google-auth-library-python/issues/651)) ([3319ea8](https://www.github.com/googleapis/google-auth-library-python/commit/3319ea8ae876c73a94f51237b3bbb3f5df2aef89)), closes [#650](https://www.github.com/googleapis/google-auth-library-python/issues/650) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). --- CHANGELOG.md | 17 +++++++++++++++++ setup.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5950234..3a06300cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ [1]: https://pypi.org/project/google-auth/#history +## [1.24.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.23.0...v1.24.0) (2020-12-11) + + +### Features + +* add Python 3.9 support, drop Python 3.5 support ([#655](https://www.github.com/googleapis/google-auth-library-python/issues/655)) ([6de753d](https://www.github.com/googleapis/google-auth-library-python/commit/6de753d585254c813b3e6cbde27bf5466261ba10)), closes [#654](https://www.github.com/googleapis/google-auth-library-python/issues/654) + + +### Bug Fixes + +* avoid losing the original '_include_email' parameter in impersonated credentials ([#626](https://www.github.com/googleapis/google-auth-library-python/issues/626)) ([fd9b5b1](https://www.github.com/googleapis/google-auth-library-python/commit/fd9b5b10c80950784bd37ee56e32c505acb5078d)) + + +### Documentation + +* fix typo in import ([#651](https://www.github.com/googleapis/google-auth-library-python/issues/651)) ([3319ea8](https://www.github.com/googleapis/google-auth-library-python/commit/3319ea8ae876c73a94f51237b3bbb3f5df2aef89)), closes [#650](https://www.github.com/googleapis/google-auth-library-python/issues/650) + ## [1.23.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.22.1...v1.23.0) (2020-10-29) diff --git a/setup.py b/setup.py index 66e74ee43..3006d9ace 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.23.0" +version = "1.24.0" setup( name="google-auth", From f062da8392c32fb3306cdc6e4dbae78212aa0dc7 Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Mon, 11 Jan 2021 08:56:03 -0800 Subject: [PATCH 30/35] chore(python): skip docfx in main presubmit (#661) * chore(python): skip docfx in main presubmit * fix: properly template the repo name Source-Author: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Source-Date: Fri Jan 8 10:32:13 2021 -0700 Source-Repo: googleapis/synthtool Source-Sha: fb53b6fb373b7c3edf4e55f3e8036bc6d73fa483 Source-Link: https://github.com/googleapis/synthtool/commit/fb53b6fb373b7c3edf4e55f3e8036bc6d73fa483 --- .kokoro/build.sh | 16 ++++++++++------ .kokoro/docs/docs-presubmit.cfg | 11 +++++++++++ synth.metadata | 4 ++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 3a63e98c6..8739d4072 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -15,7 +15,11 @@ set -eo pipefail -cd github/google-auth-library-python +if [[ -z "${PROJECT_ROOT:-}" ]]; then + PROJECT_ROOT="github/google-auth-library-python" +fi + +cd "${PROJECT_ROOT}" # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 @@ -30,16 +34,16 @@ export GOOGLE_APPLICATION_CREDENTIALS=${KOKORO_GFILE_DIR}/service-account.json export PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json") # Remove old nox -python3.6 -m pip uninstall --yes --quiet nox-automation +python3 -m pip uninstall --yes --quiet nox-automation # Install nox -python3.6 -m pip install --upgrade --quiet nox -python3.6 -m nox --version +python3 -m pip install --upgrade --quiet nox +python3 -m nox --version # If NOX_SESSION is set, it only runs the specified session, # otherwise run all the sessions. if [[ -n "${NOX_SESSION:-}" ]]; then - python3.6 -m nox -s "${NOX_SESSION:-}" + python3 -m nox -s ${NOX_SESSION:-} else - python3.6 -m nox + python3 -m nox fi diff --git a/.kokoro/docs/docs-presubmit.cfg b/.kokoro/docs/docs-presubmit.cfg index 111810782..93c606f51 100644 --- a/.kokoro/docs/docs-presubmit.cfg +++ b/.kokoro/docs/docs-presubmit.cfg @@ -15,3 +15,14 @@ env_vars: { key: "TRAMPOLINE_IMAGE_UPLOAD" value: "false" } + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: github/google-auth-library-python/.kokoro/build.sh" +} + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "docs docfx" +} diff --git a/synth.metadata b/synth.metadata index 5e1ef9a55..db9feffdd 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-python.git", - "sha": "9c4200dff31986b7ff300126e9aa35d14aa84dba" + "sha": "647290a2dfc797067f7966c1dae512359e6bb7e7" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "da5c6050d13b4950c82666a81d8acd25157664ae" + "sha": "fb53b6fb373b7c3edf4e55f3e8036bc6d73fa483" } } ], From 694e5580d37968f415b347a4310bb01cfa72f0e3 Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Tue, 12 Jan 2021 10:08:13 -0800 Subject: [PATCH 31/35] chore: add missing quotation mark (#664) Source-Author: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Source-Date: Mon Jan 11 09:43:06 2021 -0700 Source-Repo: googleapis/synthtool Source-Sha: 16ec872dd898d7de6e1822badfac32484b5d9031 Source-Link: https://github.com/googleapis/synthtool/commit/16ec872dd898d7de6e1822badfac32484b5d9031 --- .kokoro/docs/docs-presubmit.cfg | 2 +- synth.metadata | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.kokoro/docs/docs-presubmit.cfg b/.kokoro/docs/docs-presubmit.cfg index 93c606f51..d0f5783d5 100644 --- a/.kokoro/docs/docs-presubmit.cfg +++ b/.kokoro/docs/docs-presubmit.cfg @@ -18,7 +18,7 @@ env_vars: { env_vars: { key: "TRAMPOLINE_BUILD_FILE" - value: github/google-auth-library-python/.kokoro/build.sh" + value: "github/google-auth-library-python/.kokoro/build.sh" } # Only run this nox session. diff --git a/synth.metadata b/synth.metadata index db9feffdd..0de642bf7 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-python.git", - "sha": "647290a2dfc797067f7966c1dae512359e6bb7e7" + "sha": "f062da8392c32fb3306cdc6e4dbae78212aa0dc7" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "fb53b6fb373b7c3edf4e55f3e8036bc6d73fa483" + "sha": "16ec872dd898d7de6e1822badfac32484b5d9031" } } ], From 621f54bd70eded4f68d2caa9b894d5504117452b Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Fri, 22 Jan 2021 13:43:41 -0700 Subject: [PATCH 32/35] test: re-enable system tests (#670) --- .gitignore | 1 + .kokoro/build.sh | 16 +++++++++++- .kokoro/continuous/common.cfg | 2 +- .kokoro/presubmit/common.cfg | 2 +- synth.py | 6 ++--- system_tests/__init__.py | 0 system_tests/noxfile.py | 10 ++++---- system_tests/secrets.tar.enc | Bin 0 -> 10323 bytes system_tests/system_tests_async/__init__.py | 0 system_tests/system_tests_async/conftest.py | 3 ++- .../system_tests_async/test_default.py | 5 ++-- .../app_engine_test_app/requirements.txt | 2 +- .../system_tests_sync/secrets.tar.enc | Bin 10323 -> 0 bytes system_tests/system_tests_sync/test_grpc.py | 23 +++--------------- .../system_tests_sync/test_mtls_http.py | 5 +++- 15 files changed, 38 insertions(+), 37 deletions(-) create mode 100644 system_tests/__init__.py create mode 100644 system_tests/secrets.tar.enc create mode 100644 system_tests/system_tests_async/__init__.py delete mode 100644 system_tests/system_tests_sync/secrets.tar.enc diff --git a/.gitignore b/.gitignore index f01e60ec0..1f0b7e3c7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ scripts/local_test_setup tests/data/key.json tests/data/key.p12 tests/data/user-key.json +system_tests/data/ # PyCharm configuration: .idea diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 8739d4072..1f96e21d7 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -31,7 +31,14 @@ env | grep KOKORO export GOOGLE_APPLICATION_CREDENTIALS=${KOKORO_GFILE_DIR}/service-account.json # Setup project id. -export PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json") +export PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.txt") + +# Activate gcloud with service account credentials +gcloud auth activate-service-account --key-file=$GOOGLE_APPLICATION_CREDENTIALS +gcloud config set project ${PROJECT_ID} + +# Decrypt system test secrets +./scripts/decrypt-secrets.sh # Remove old nox python3 -m pip uninstall --yes --quiet nox-automation @@ -47,3 +54,10 @@ if [[ -n "${NOX_SESSION:-}" ]]; then else python3 -m nox fi + + +# Decrypt system test secrets +./scripts/decrypt-secrets.sh + +# Run system tests which use a different noxfile +python3 -m nox -f system_tests/noxfile.py \ No newline at end of file diff --git a/.kokoro/continuous/common.cfg b/.kokoro/continuous/common.cfg index c587b4104..10910e357 100644 --- a/.kokoro/continuous/common.cfg +++ b/.kokoro/continuous/common.cfg @@ -11,7 +11,7 @@ action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Download resources for system tests (service account key, etc.) -gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-cloud-python" +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-auth-library-python" # Use the trampoline script to run in docker. build_file: "google-auth-library-python/.kokoro/trampoline.sh" diff --git a/.kokoro/presubmit/common.cfg b/.kokoro/presubmit/common.cfg index c587b4104..10910e357 100644 --- a/.kokoro/presubmit/common.cfg +++ b/.kokoro/presubmit/common.cfg @@ -11,7 +11,7 @@ action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Download resources for system tests (service account key, etc.) -gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-cloud-python" +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-auth-library-python" # Use the trampoline script to run in docker. build_file: "google-auth-library-python/.kokoro/trampoline.sh" diff --git a/synth.py b/synth.py index 49bf2dda6..f692f7010 100644 --- a/synth.py +++ b/synth.py @@ -10,8 +10,8 @@ s.move( templated_files / ".kokoro", excludes=[ - ".kokoro/continuous/common.cfg", - ".kokoro/presubmit/common.cfg", - ".kokoro/build.sh", + "continuous/common.cfg", + "presubmit/common.cfg", + "build.sh", ], ) # just move kokoro configs diff --git a/system_tests/__init__.py b/system_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index dcfe8ee81..5d0014bc8 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -30,7 +30,7 @@ import py.path HERE = os.path.abspath(os.path.dirname(__file__)) -LIBRARY_DIR = os.path.join(HERE, "..") +LIBRARY_DIR = os.path.abspath(os.path.dirname(HERE)) DATA_DIR = os.path.join(HERE, "data") SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, "service_account.json") AUTHORIZED_USER_FILE = os.path.join(DATA_DIR, "authorized_user.json") @@ -169,7 +169,7 @@ def configure_cloud_sdk(session, application_default_credentials, project=False) # Test sesssions TEST_DEPENDENCIES_ASYNC = ["aiohttp", "pytest-asyncio", "nest-asyncio"] -TEST_DEPENDENCIES_SYNC = ["pytest", "requests"] +TEST_DEPENDENCIES_SYNC = ["pytest", "requests", "mock"] PYTHON_VERSIONS_ASYNC = ["3.7"] PYTHON_VERSIONS_SYNC = ["2.7", "3.7"] @@ -249,6 +249,7 @@ def app_engine(session): session.log("Skipping App Engine tests.") return + session.install(LIBRARY_DIR) # Unlike the default tests above, the App Engine system test require a # 'real' gcloud sdk installation that is configured to deploy to an # app engine project. @@ -269,9 +270,8 @@ def app_engine(session): application_url = GAE_APP_URL_TMPL.format(GAE_TEST_APP_SERVICE, project_id) # Vendor in the test application's dependencies - session.chdir(os.path.join(HERE, "../app_engine_test_app")) + session.chdir(os.path.join(HERE, "system_tests_sync/app_engine_test_app")) session.install(*TEST_DEPENDENCIES_SYNC) - session.install(LIBRARY_DIR) session.run( "pip", "install", "--target", "lib", "-r", "requirements.txt", silent=True ) @@ -288,7 +288,7 @@ def app_engine(session): @nox.session(python=PYTHON_VERSIONS_SYNC) def grpc(session): session.install(LIBRARY_DIR) - session.install(*TEST_DEPENDENCIES_SYNC, "google-cloud-pubsub==1.0.0") + session.install(*TEST_DEPENDENCIES_SYNC, "google-cloud-pubsub==1.7.0") session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE session.run("pytest", "system_tests_sync/test_grpc.py") diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc new file mode 100644 index 0000000000000000000000000000000000000000..29e06923f0f028b54d1b571dc218cdd92f751bd8 GIT binary patch literal 10323 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 diff --git a/system_tests/system_tests_sync/test_grpc.py b/system_tests/system_tests_sync/test_grpc.py index 650fa96a4..7dcbd4c43 100644 --- a/system_tests/system_tests_sync/test_grpc.py +++ b/system_tests/system_tests_sync/test_grpc.py @@ -17,8 +17,6 @@ import google.auth.jwt import google.auth.transport.grpc from google.cloud import pubsub_v1 -from google.cloud.pubsub_v1.gapic import publisher_client -from google.cloud.pubsub_v1.gapic.transports import publisher_grpc_transport def test_grpc_request_with_regular_credentials(http_request): @@ -27,13 +25,8 @@ def test_grpc_request_with_regular_credentials(http_request): credentials, ["https://www.googleapis.com/auth/pubsub"] ) - transport = publisher_grpc_transport.PublisherGrpcTransport( - address=publisher_client.PublisherClient.SERVICE_ADDRESS, - credentials=credentials, - ) - # Create a pub/sub client. - client = pubsub_v1.PublisherClient(transport=transport) + client = pubsub_v1.PublisherClient(credentials=credentials) # list the topics and drain the iterator to test that an authorized API # call works. @@ -48,13 +41,8 @@ def test_grpc_request_with_jwt_credentials(): credentials, audience=audience ) - transport = publisher_grpc_transport.PublisherGrpcTransport( - address=publisher_client.PublisherClient.SERVICE_ADDRESS, - credentials=credentials, - ) - # Create a pub/sub client. - client = pubsub_v1.PublisherClient(transport=transport) + client = pubsub_v1.PublisherClient(credentials=credentials) # list the topics and drain the iterator to test that an authorized API # call works. @@ -68,13 +56,8 @@ def test_grpc_request_with_on_demand_jwt_credentials(): credentials ) - transport = publisher_grpc_transport.PublisherGrpcTransport( - address=publisher_client.PublisherClient.SERVICE_ADDRESS, - credentials=credentials, - ) - # Create a pub/sub client. - client = pubsub_v1.PublisherClient(transport=transport) + client = pubsub_v1.PublisherClient(credentials=credentials) # list the topics and drain the iterator to test that an authorized API # call works. diff --git a/system_tests/system_tests_sync/test_mtls_http.py b/system_tests/system_tests_sync/test_mtls_http.py index 7c5649685..bcf2a59da 100644 --- a/system_tests/system_tests_sync/test_mtls_http.py +++ b/system_tests/system_tests_sync/test_mtls_http.py @@ -13,8 +13,11 @@ # limitations under the License. import json -from os import path +import mock +import os import time +from os import path + import google.auth import google.auth.credentials From d75f57a830593f6a10e3ae9bec53b15b5f646e1c Mon Sep 17 00:00:00 2001 From: Bu Sun Kim Date: Fri, 22 Jan 2021 21:23:16 +0000 Subject: [PATCH 33/35] test: add secrest --- system_tests/secrets.tar.enc | Bin 0 -> 10323 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 system_tests/secrets.tar.enc diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc new file mode 100644 index 0000000000000000000000000000000000000000..29e06923f0f028b54d1b571dc218cdd92f751bd8 GIT binary patch literal 10323 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 Date: Fri, 22 Jan 2021 21:34:00 +0000 Subject: [PATCH 34/35] build: fix build.sh --- .kokoro/build.sh | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 2f87b3e19..1f96e21d7 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -59,12 +59,5 @@ fi # Decrypt system test secrets ./scripts/decrypt-secrets.sh -# If NOX_SESSION is set, it only runs the specified session, -# otherwise run all the sessions. -if [[ -n "${NOX_SESSION:-}" ]]; then - python3.6 -m nox -s "${NOX_SESSION:-}" -else - python3.6 -m nox -fi # Run system tests which use a different noxfile -python3 -m nox -f system_tests/noxfile.py +python3 -m nox -f system_tests/noxfile.py \ No newline at end of file From a542a9b1a926f5eed9ec2925746737c056ea9c5f Mon Sep 17 00:00:00 2001 From: Bu Sun Kim Date: Fri, 22 Jan 2021 22:11:35 +0000 Subject: [PATCH 35/35] test: add __init__.py so import succeeds --- system_tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 system_tests/__init__.py diff --git a/system_tests/__init__.py b/system_tests/__init__.py new file mode 100644 index 000000000..e69de29bb