diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..3ccb781a0 Binary files /dev/null and b/.DS_Store differ 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/google/oauth2/sts.py b/google/oauth2/sts.py new file mode 100644 index 000000000..ae3c0146b --- /dev/null +++ b/google/oauth2/sts.py @@ -0,0 +1,155 @@ +# 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 Token Exchange Spec. + +This module defines a token exchange utility based on the `OAuth 2.0 Token +Exchange`_ spec. This will be mainly used to exchange external credentials +for GCP access tokens in workload identity pools to access Google APIs. + +The implementation will support various types of client authentication as +allowed in the spec. + +A deviation on the spec will be for additional Google specific options that +cannot be easily mapped to parameters defined in the RFC. + +The returned dictionary response will be based on the `rfc8693 section 2.2.1`_ +spec JSON response. + +.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693 +.. _rfc8693 section 2.2.1: https://tools.ietf.org/html/rfc8693#section-2.2.1 +""" + +import json + +from six.moves import http_client +from six.moves import urllib + +from google.oauth2 import utils + + +_URLENCODED_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"} + + +class Client(utils.OAuthClientAuthHandler): + """Implements the OAuth 2.0 token exchange spec based on + https://tools.ietf.org/html/rfc8693. + """ + + def __init__(self, token_exchange_endpoint, client_authentication=None): + """Initializes an STS client instance. + + Args: + token_exchange_endpoint (str): The token exchange endpoint. + client_authentication (Optional(google.oauth2.oauth2_utils.ClientAuthentication)): + The optional OAuth client authentication credentials if available. + """ + super(Client, self).__init__(client_authentication) + self._token_exchange_endpoint = token_exchange_endpoint + + def exchange_token( + self, + request, + grant_type, + subject_token, + subject_token_type, + resource=None, + audience=None, + scopes=None, + requested_token_type=None, + actor_token=None, + actor_token_type=None, + additional_options=None, + additional_headers=None, + ): + """Exchanges the provided token for another type of token based on the + rfc8693 spec. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + grant_type (str): The OAuth 2.0 token exchange grant type. + subject_token (str): The OAuth 2.0 token exchange subject token. + subject_token_type (str): The OAuth 2.0 token exchange subject token type. + resource (Optional[str]): The optional OAuth 2.0 token exchange resource field. + audience (Optional[str]): The optional OAuth 2.0 token exchange audience field. + scopes (Optional[Sequence[str]]): The optional list of scopes to use. + requested_token_type (Optional[str]): The optional OAuth 2.0 token exchange requested + token type. + actor_token (Optional[str]): The optional OAuth 2.0 token exchange actor token. + actor_token_type (Optional[str]): The optional OAuth 2.0 token exchange actor token type. + additional_options (Optional[Mapping[str, str]]): The optional additional + non-standard Google specific options. + additional_headers (Optional[Mapping[str, str]]): The optional additional + headers to pass to the token exchange endpoint. + + Returns: + Mapping[str, str]: The token exchange JSON-decoded response data containing + the requested token and its expiration time. + + Raises: + google.auth.exceptions.OAuthError: If the token endpoint returned + an error. + """ + # Initialize request headers. + headers = _URLENCODED_HEADERS.copy() + # Inject additional headers. + if additional_headers: + for k, v in dict(additional_headers).items(): + headers[k] = v + # Initialize request body. + request_body = { + "grant_type": grant_type, + "resource": resource, + "audience": audience, + "scope": " ".join(scopes or []), + "requested_token_type": requested_token_type, + "subject_token": subject_token, + "subject_token_type": subject_token_type, + "actor_token": actor_token, + "actor_token_type": actor_token_type, + "options": None, + } + # Add additional non-standard options. + if additional_options: + request_body["options"] = urllib.parse.quote(json.dumps(additional_options)) + # Remove empty fields in request body. + for k, v in dict(request_body).items(): + if v is None or v == "": + del request_body[k] + # Apply OAuth client authentication. + self.apply_client_authentication_options(headers, request_body) + + # Execute request. + response = request( + url=self._token_exchange_endpoint, + method="POST", + headers=headers, + body=urllib.parse.urlencode(request_body).encode("utf-8"), + ) + + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + # If non-200 response received, translate to OAuthError exception. + if response.status != http_client.OK: + utils.handle_error_response(response_body) + + response_data = json.loads(response_body) + + # Return successful response. + return response_data diff --git a/tests/oauth2/test_sts.py b/tests/oauth2/test_sts.py new file mode 100644 index 000000000..8792bd6bc --- /dev/null +++ b/tests/oauth2/test_sts.py @@ -0,0 +1,395 @@ +# 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 mock +import pytest +from six.moves import http_client +from six.moves import urllib + +from google.auth import exceptions +from google.auth import transport +from google.oauth2 import sts +from google.oauth2 import utils + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password" +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" + + +class TestStsClient(object): + GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + RESOURCE = "https://api.example.com/" + AUDIENCE = "urn:example:cooperation-context" + SCOPES = ["scope1", "scope2"] + REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + SUBJECT_TOKEN = "HEADER.SUBJECT_TOKEN_PAYLOAD.SIGNATURE" + SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + ACTOR_TOKEN = "HEADER.ACTOR_TOKEN_PAYLOAD.SIGNATURE" + ACTOR_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + TOKEN_EXCHANGE_ENDPOINT = "https://example.com/token.oauth2" + ADDON_HEADERS = {"x-client-version": "0.1.2"} + ADDON_OPTIONS = {"additional": {"non-standard": ["options"], "other": "some-value"}} + 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", + } + CLIENT_AUTH_BASIC = utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID, CLIENT_SECRET + ) + CLIENT_AUTH_REQUEST_BODY = utils.ClientAuthentication( + utils.ClientAuthType.request_body, CLIENT_ID, CLIENT_SECRET + ) + + @classmethod + def make_client(cls, client_auth=None): + return sts.Client(cls.TOKEN_EXCHANGE_ENDPOINT, client_auth) + + @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): + """Asserts the request was called with the expected parameters. + """ + assert request_kwargs["url"] == cls.TOKEN_EXCHANGE_ENDPOINT + 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_exchange_token_full_success_without_auth(self): + """Test token exchange success without client authentication using full + parameters. + """ + client = self.make_client() + headers = self.ADDON_HEADERS.copy() + headers["Content-Type"] = "application/x-www-form-urlencoded" + request_data = { + "grant_type": self.GRANT_TYPE, + "resource": self.RESOURCE, + "audience": self.AUDIENCE, + "scope": " ".join(self.SCOPES), + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "actor_token": self.ACTOR_TOKEN, + "actor_token_type": self.ACTOR_TOKEN_TYPE, + "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)), + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_partial_success_without_auth(self): + """Test token exchange success without client authentication using + partial (required only) parameters. + """ + client = self.make_client() + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": self.GRANT_TYPE, + "audience": self.AUDIENCE, + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + grant_type=self.GRANT_TYPE, + subject_token=self.SUBJECT_TOKEN, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + audience=self.AUDIENCE, + requested_token_type=self.REQUESTED_TOKEN_TYPE, + ) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_non200_without_auth(self): + """Test token exchange without client auth responding with non-200 status. + """ + client = self.make_client() + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test_exchange_token_full_success_with_basic_auth(self): + """Test token exchange success with basic client authentication using full + parameters. + """ + client = self.make_client(self.CLIENT_AUTH_BASIC) + headers = self.ADDON_HEADERS.copy() + headers["Content-Type"] = "application/x-www-form-urlencoded" + headers["Authorization"] = "Basic {}".format(BASIC_AUTH_ENCODING) + request_data = { + "grant_type": self.GRANT_TYPE, + "resource": self.RESOURCE, + "audience": self.AUDIENCE, + "scope": " ".join(self.SCOPES), + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "actor_token": self.ACTOR_TOKEN, + "actor_token_type": self.ACTOR_TOKEN_TYPE, + "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)), + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_partial_success_with_basic_auth(self): + """Test token exchange success with basic client authentication using + partial (required only) parameters. + """ + client = self.make_client(self.CLIENT_AUTH_BASIC) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + } + request_data = { + "grant_type": self.GRANT_TYPE, + "audience": self.AUDIENCE, + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + grant_type=self.GRANT_TYPE, + subject_token=self.SUBJECT_TOKEN, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + audience=self.AUDIENCE, + requested_token_type=self.REQUESTED_TOKEN_TYPE, + ) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_non200_with_basic_auth(self): + """Test token exchange with basic client auth responding with non-200 + status. + """ + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + + def test_exchange_token_full_success_with_reqbody_auth(self): + """Test token exchange success with request body client authenticaiton + using full parameters. + """ + client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY) + headers = self.ADDON_HEADERS.copy() + headers["Content-Type"] = "application/x-www-form-urlencoded" + request_data = { + "grant_type": self.GRANT_TYPE, + "resource": self.RESOURCE, + "audience": self.AUDIENCE, + "scope": " ".join(self.SCOPES), + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "actor_token": self.ACTOR_TOKEN, + "actor_token_type": self.ACTOR_TOKEN_TYPE, + "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)), + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_partial_success_with_reqbody_auth(self): + """Test token exchange success with request body client authentication + using partial (required only) parameters. + """ + client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": self.GRANT_TYPE, + "audience": self.AUDIENCE, + "requested_token_type": self.REQUESTED_TOKEN_TYPE, + "subject_token": self.SUBJECT_TOKEN, + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + + response = client.exchange_token( + request, + grant_type=self.GRANT_TYPE, + subject_token=self.SUBJECT_TOKEN, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + audience=self.AUDIENCE, + requested_token_type=self.REQUESTED_TOKEN_TYPE, + ) + + self.assert_request_kwargs(request.call_args.kwargs, headers, request_data) + assert response == self.SUCCESS_RESPONSE + + def test_exchange_token_non200_with_reqbody_auth(self): + """Test token exchange with POST request body client auth responding + with non-200 status. + """ + client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.exchange_token( + request, + self.GRANT_TYPE, + self.SUBJECT_TOKEN, + self.SUBJECT_TOKEN_TYPE, + self.RESOURCE, + self.AUDIENCE, + self.SCOPES, + self.REQUESTED_TOKEN_TYPE, + self.ACTOR_TOKEN, + self.ACTOR_TOKEN_TYPE, + self.ADDON_OPTIONS, + self.ADDON_HEADERS, + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) 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):