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/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/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 diff --git a/google/auth/external_account.py b/google/auth/external_account.py new file mode 100644 index 000000000..6b1764a0a --- /dev/null +++ b/google/auth/external_account.py @@ -0,0 +1,162 @@ +# 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. + +"""External Account Credentials. + +This module provides credentials that exchange workload identity pool external +credentials for Google access tokens. This facilitates calling Google APIs from +Kubernetes, Azure and AWS workloads securely, using native credentials retrieved +from the current environment without the need to copy, save and manage service +account keys. + +Specifically, this is intended to use access tokens acquired using the GCP STS +token exchange endpoint following the `OAuth 2.0 Token Exchange`_ spec. + +.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693 +""" + +import abc +import datetime + +import six + +from google.auth import _helpers +from google.auth import credentials +from google.oauth2 import sts +from google.oauth2 import utils + +# The token exchange grant_type used for exchanging credentials. +_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +# The token exchange requested_token_type. This is always an access_token. +_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + + +@six.add_metaclass(abc.ABCMeta) +class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject): + """Base class for all external account credentials. + + This is used to instantiate Credentials for exchanging external account + credentials for Google access token and authorizing requests to Google APIs. + The base class implements the common logic for exchanging external account + credentials for Google access tokens. + """ + + def __init__( + self, + audience, + subject_token_type, + token_url, + credential_source, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + ): + """Instantiates an external account credentials object. + + Args: + audience (str): The STS audience field. + subject_token_type (str): The subject token type. + token_url (str): The STS endpoint URL. + credential_source (Mapping): The credential source dictionary. + client_id (Optional[str]): The optional client ID. + client_secret (Optional[str]): The optional client secret. + quota_project_id (Optional[str]): The optional quota project ID. + scopes (Optional[Sequence[str]]): Optional scopes to request during the + authorization grant. + """ + super(Credentials, self).__init__() + self._audience = audience + self._subject_token_type = subject_token_type + self._token_url = token_url + self._credential_source = credential_source + self._client_id = client_id + self._client_secret = client_secret + self._quota_project_id = quota_project_id + self._scopes = scopes + + if self._client_id: + self._client_auth = utils.ClientAuthentication( + utils.ClientAuthType.basic, self._client_id, self._client_secret + ) + else: + self._client_auth = None + self._sts_client = sts.Client(self._token_url, self._client_auth) + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return True if not self._scopes else False + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes): + return self.__class__( + audience=self._audience, + subject_token_type=self._subject_token_type, + token_url=self._token_url, + credential_source=self._credential_source, + client_id=self._client_id, + client_secret=self._client_secret, + quota_project_id=self._quota_project_id, + scopes=scopes, + ) + + @abc.abstractmethod + def retrieve_subject_token(self, request): + """Retrieves the subject token using the credential_source object. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + Returns: + str: The retrieved subject token. + """ + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError("retrieve_subject_token must be implemented") + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + now = _helpers.utcnow() + response_data = self._sts_client.exchange_token( + request=request, + grant_type=_STS_GRANT_TYPE, + subject_token=self.retrieve_subject_token(request), + subject_token_type=self._subject_token_type, + audience=self._audience, + scopes=self._scopes, + requested_token_type=_STS_REQUESTED_TOKEN_TYPE, + ) + + self.token = response_data.get("access_token") + lifetime = datetime.timedelta(seconds=response_data.get("expires_in")) + self.expiry = now + lifetime + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + # Return copy of instance with the provided quota project ID. + return self.__class__( + audience=self._audience, + subject_token_type=self._subject_token_type, + token_url=self._token_url, + credential_source=self._credential_source, + client_id=self._client_id, + client_secret=self._client_secret, + quota_project_id=quota_project_id, + scopes=self._scopes, + ) 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/setup.py b/setup.py index 8a8a7e1da..9a3493106 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.0" +version = "1.21.1" setup( name="google-auth", 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_external_account.py b/tests/test_external_account.py new file mode 100644 index 000000000..0c8dcccc0 --- /dev/null +++ b/tests/test_external_account.py @@ -0,0 +1,341 @@ +# 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 +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 external_account +from google.auth import transport + + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password" +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" + + +class CredentialsImpl(external_account.Credentials): + def __init__( + self, + audience, + subject_token_type, + token_url, + credential_source, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + ): + super(CredentialsImpl, self).__init__( + audience, + subject_token_type, + token_url, + credential_source, + client_id, + client_secret, + quota_project_id, + scopes, + ) + self._counter = 0 + + def retrieve_subject_token(self, request): + counter = self._counter + self._counter += 1 + return "subject_token_{}".format(counter) + + +class TestCredentials(object): + TOKEN_URL = "https://sts.googleapis.com/v1/token" + PROJECT_NUMBER = "123456" + POOL_ID = "POOL_ID" + PROVIDER_ID = "PROVIDER_ID" + AUDIENCE = ( + "//iam.googleapis.com/project/{}" + "/locations/global/workloadIdentityPools/{}" + "/providers/{}" + ).format(PROJECT_NUMBER, POOL_ID, PROVIDER_ID) + SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + CREDENTIAL_SOURCE = {"file": "/var/run/secrets/goog.id/token"} + SUCCESS_RESPONSE = { + "access_token": "ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "scope1 scope2", + } + ERROR_RESPONSE = { + "error": "invalid_request", + "error_description": "Invalid subject token", + "error_uri": "https://tools.ietf.org/html/rfc6749", + } + QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" + + @classmethod + def make_credentials( + cls, client_id=None, client_secret=None, quota_project_id=None, scopes=None + ): + return CredentialsImpl( + audience=cls.AUDIENCE, + subject_token_type=cls.SUBJECT_TOKEN_TYPE, + token_url=cls.TOKEN_URL, + credential_source=cls.CREDENTIAL_SOURCE, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + ) + + @classmethod + def make_mock_request(cls, data, status=http_client.OK): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + response.data = json.dumps(data).encode("utf-8") + + request = mock.create_autospec(transport.Request) + request.return_value = response + + return request + + @classmethod + def assert_request_kwargs(cls, request_kwargs, headers, request_data): + assert request_kwargs["url"] == cls.TOKEN_URL + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + for (k, v) in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + assert len(body_tuples) == len(request_data.keys()) + + def test_default_state(self): + credentials = self.make_credentials() + + # Not token acquired yet + assert not credentials.token + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expiry + assert not credentials.expired + # Scopes are required + assert not credentials.scopes + assert credentials.requires_scopes + assert not credentials.quota_project_id + + def test_with_scopes(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes(["email"]) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.requires_scopes + + def test_with_quota_project(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert not credentials.quota_project_id + + quota_project_creds = credentials.with_quota_project("project-foo") + + assert quota_project_creds.quota_project_id == "project-foo" + + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_without_client_auth_success(self, unused_utcnow): + response = self.SUCCESS_RESPONSE.copy() + # Test custom expiration to confirm expiry is set correctly. + response["expires_in"] = 2800 + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=response["expires_in"] + ) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request(status=http_client.OK, data=response) + credentials = self.make_credentials() + + credentials.refresh(request) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == response["access_token"] + + def test_refresh_without_client_auth_success_explicit_scopes(self): + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "scope1 scope2", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials(scopes=["scope1", "scope2"]) + + credentials.refresh(request) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert credentials.valid + assert not credentials.expired + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.has_scopes(["scope1", "scope2"]) + + def test_refresh_without_client_auth_error(self): + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + credentials = self.make_credentials() + + with pytest.raises(exceptions.OAuthError) as excinfo: + credentials.refresh(request) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + assert not credentials.expired + assert credentials.token is None + + def test_refresh_with_client_auth_success(self): + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + } + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, client_secret=CLIENT_SECRET + ) + + credentials.refresh(request) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert credentials.valid + assert not credentials.expired + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + + def test_apply_without_quota_project_id(self): + headers = {} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]) + } + + def test_apply_with_quota_project_id(self): + headers = {"other": "header-value"} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials(quota_project_id=self.QUOTA_PROJECT_ID) + + credentials.refresh(request) + credentials.apply(headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + "x-goog-user-project": self.QUOTA_PROJECT_ID, + } + + def test_before_request(self): + headers = {"other": "header-value"} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + + # First call should call refresh, setting the token. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + } + + # Second call shouldn't call refresh. + credentials.before_request(request, "POST", "https://example.com/api", headers) + + assert headers == { + "other": "header-value", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + } + + @mock.patch("google.auth._helpers.utcnow") + def test_before_request_expired(self, utcnow): + headers = {} + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials() + credentials.token = "token" + utcnow.return_value = datetime.datetime.min + # Set the expiration to one second more than now plus the clock skew + # accomodation. These credentials should be valid. + credentials.expiry = ( + datetime.datetime.min + _helpers.CLOCK_SKEW + datetime.timedelta(seconds=1) + ) + + assert credentials.valid + assert not credentials.expired + + credentials.before_request(request, "POST", "https://example.com/api", headers) + + # Cached token should be used. + assert headers == {"authorization": "Bearer token"} + + # Next call should simulate 1 second passed. + utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=1) + + assert not credentials.valid + assert credentials.expired + + credentials.before_request(request, "POST", "https://example.com/api", headers) + + # New token should be retrieved. + assert headers == { + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]) + } 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)