diff --git a/google/auth/exceptions.py b/google/auth/exceptions.py index da06d8696..b6f686bbb 100644 --- a/google/auth/exceptions.py +++ b/google/auth/exceptions.py @@ -43,3 +43,8 @@ class MutualTLSChannelError(GoogleAuthError): class ClientCertError(GoogleAuthError): """Used to indicate that client certificate is missing or invalid.""" + + +class OAuthError(GoogleAuthError): + """Used to indicate an error occurred during an OAuth related HTTP + request.""" diff --git a/google/oauth2/utils.py b/google/oauth2/utils.py new file mode 100644 index 000000000..efda7968d --- /dev/null +++ b/google/oauth2/utils.py @@ -0,0 +1,171 @@ +# 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 Utilities. + +This module provides implementations for various OAuth 2.0 utilities. +This includes `OAuth error handling`_ and +`Client authentication for OAuth flows`_. + +OAuth error handling +-------------------- +This will define interfaces for handling OAuth related error responses as +stated in `RFC 6749 section 5.2`_. +This will include a common function to convert these HTTP error responses to a +:class:`google.auth.exceptions.OAuthError` exception. + + +Client authentication for OAuth flows +------------------------------------- +We introduce an interface for defining client authentication credentials based +on `RFC 6749 section 2.3.1`_. This will expose the following +capabilities: + + * Ability to support basic authentication via request header. + * Ability to support bearer token authentication via request header. + * Ability to support client ID / secret authentication via request body. + +.. _RFC 6749 section 2.3.1: https://tools.ietf.org/html/rfc6749#section-2.3.1 +.. _RFC 6749 section 5.2: https://tools.ietf.org/html/rfc6749#section-5.2 +""" + +import abc +import base64 +import enum +import json + +import six + +from google.auth import exceptions + + +# OAuth client authentication based on +# https://tools.ietf.org/html/rfc6749#section-2.3. +class ClientAuthType(enum.Enum): + basic = 1 + request_body = 2 + + +class ClientAuthentication(object): + """Defines the client authentication credentials for basic and request-body + types based on https://tools.ietf.org/html/rfc6749#section-2.3.1. + """ + + def __init__(self, client_auth_type, client_id, client_secret=None): + """Instantiates a client authentication object containing the client ID + and secret credentials for basic and response-body auth. + + Args: + client_auth_type (google.oauth2.oauth_utils.ClientAuthType): The + client authentication type. + client_id (str): The client ID. + client_secret (Optional[str]): The client secret. + """ + self.client_auth_type = client_auth_type + self.client_id = client_id + self.client_secret = client_secret + + +@six.add_metaclass(abc.ABCMeta) +class OAuthClientAuthHandler(object): + """Abstract class for handling client authentication in OAuth-based + operations. + """ + + def __init__(self, client_authentication=None): + """Instantiates an OAuth client authentication handler. + + Args: + client_authentication (Optional[google.oauth2.utils.ClientAuthentication]): + The OAuth client authentication credentials if available. + """ + super(OAuthClientAuthHandler, self).__init__() + self._client_authentication = client_authentication + + def apply_client_authentication_options( + self, headers, request_body=None, bearer_token=None + ): + """Applies client authentication on the OAuth request's headers or POST + body. + + Args: + headers (Mapping[str, str]): The HTTP request header. + request_body (Optional[Mapping[str, str]): The HTTP request body + dictionary. For requests that do not support request body, this + is None and will be ignored. + bearer_token (Optional[str]): The optional bearer token. + """ + # Inject authenticated header. + self._inject_authenticated_headers(headers, bearer_token) + # Inject authenticated request body. + if bearer_token is None: + self._inject_authenticated_request_body(request_body) + + def _inject_authenticated_headers(self, headers, bearer_token=None): + if bearer_token is not None: + headers["Authorization"] = "Bearer %s" % bearer_token + elif ( + self._client_authentication is not None + and self._client_authentication.client_auth_type is ClientAuthType.basic + ): + username = self._client_authentication.client_id + password = self._client_authentication.client_secret or "" + + credentials = base64.b64encode( + ("%s:%s" % (username, password)).encode() + ).decode() + headers["Authorization"] = "Basic %s" % credentials + + def _inject_authenticated_request_body(self, request_body): + if ( + self._client_authentication is not None + and self._client_authentication.client_auth_type + is ClientAuthType.request_body + ): + if request_body is None: + raise exceptions.OAuthError( + "HTTP request does not support request-body" + ) + else: + request_body["client_id"] = self._client_authentication.client_id + request_body["client_secret"] = ( + self._client_authentication.client_secret or "" + ) + + +def handle_error_response(response_body): + """Translates an error response from an OAuth operation into an + OAuthError exception. + + Args: + response_body (str): The decoded response data. + + Raises: + google.auth.exceptions.OAuthError + """ + try: + error_components = [] + error_data = json.loads(response_body) + + error_components.append("Error code {}".format(error_data["error"])) + if "error_description" in error_data: + error_components.append(": {}".format(error_data["error_description"])) + if "error_uri" in error_data: + error_components.append(" - {}".format(error_data["error_uri"])) + error_details = "".join(error_components) + # If no details could be extracted, use the response data. + except (KeyError, ValueError): + error_details = response_body + + raise exceptions.OAuthError(error_details, response_body) diff --git a/setup.py b/setup.py index c0365391e..8a8a7e1da 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ DEPENDENCIES = ( "cachetools>=2.0.0,<5.0", + 'enum34; python_version < "3.4"', "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 diff --git a/tests/oauth2/test_utils.py b/tests/oauth2/test_utils.py new file mode 100644 index 000000000..6de9ff533 --- /dev/null +++ b/tests/oauth2/test_utils.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. + +import json + +import pytest + +from google.auth import exceptions +from google.oauth2 import utils + + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password" +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +# Base64 encoding of "username:" +BASIC_AUTH_ENCODING_SECRETLESS = "dXNlcm5hbWU6" + + +class AuthHandler(utils.OAuthClientAuthHandler): + def __init__(self, client_auth=None): + super(AuthHandler, self).__init__(client_auth) + + def apply_client_authentication_options( + self, headers, request_body=None, bearer_token=None + ): + return super(AuthHandler, self).apply_client_authentication_options( + headers, request_body, bearer_token + ) + + +class TestClientAuthentication(object): + @classmethod + def make_client_auth(cls, client_secret=None): + return utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID, client_secret + ) + + def test_initialization_with_client_secret(self): + client_auth = self.make_client_auth(CLIENT_SECRET) + + assert client_auth.client_auth_type == utils.ClientAuthType.basic + assert client_auth.client_id == CLIENT_ID + assert client_auth.client_secret == CLIENT_SECRET + + def test_initialization_no_client_secret(self): + client_auth = self.make_client_auth() + + assert client_auth.client_auth_type == utils.ClientAuthType.basic + assert client_auth.client_id == CLIENT_ID + assert client_auth.client_secret is None + + +class TestOAuthClientAuthHandler(object): + CLIENT_AUTH_BASIC = utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID, CLIENT_SECRET + ) + CLIENT_AUTH_BASIC_SECRETLESS = utils.ClientAuthentication( + utils.ClientAuthType.basic, CLIENT_ID + ) + CLIENT_AUTH_REQUEST_BODY = utils.ClientAuthentication( + utils.ClientAuthType.request_body, CLIENT_ID, CLIENT_SECRET + ) + CLIENT_AUTH_REQUEST_BODY_SECRETLESS = utils.ClientAuthentication( + utils.ClientAuthType.request_body, CLIENT_ID + ) + + @classmethod + def make_oauth_client_auth_handler(cls, client_auth=None): + return AuthHandler(client_auth) + + def test_apply_client_authentication_options_none(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler() + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == {"Content-Type": "application/json"} + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_basic(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler(self.CLIENT_AUTH_BASIC) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == { + "Content-Type": "application/json", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_basic_nosecret(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_BASIC_SECRETLESS + ) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == { + "Content-Type": "application/json", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING_SECRETLESS), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_request_body(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY + ) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == {"Content-Type": "application/json"} + assert request_body == { + "foo": "bar", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + } + + def test_apply_client_authentication_options_request_body_nosecret(self): + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY_SECRETLESS + ) + + auth_handler.apply_client_authentication_options(headers, request_body) + + assert headers == {"Content-Type": "application/json"} + assert request_body == { + "foo": "bar", + "client_id": CLIENT_ID, + "client_secret": "", + } + + def test_apply_client_authentication_options_request_body_no_body(self): + headers = {"Content-Type": "application/json"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + auth_handler.apply_client_authentication_options(headers) + + assert excinfo.match(r"HTTP request does not support request-body") + + def test_apply_client_authentication_options_bearer_token(self): + bearer_token = "ACCESS_TOKEN" + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler() + + auth_handler.apply_client_authentication_options( + headers, request_body, bearer_token + ) + + assert headers == { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(bearer_token), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_bearer_and_basic(self): + bearer_token = "ACCESS_TOKEN" + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler(self.CLIENT_AUTH_BASIC) + + auth_handler.apply_client_authentication_options( + headers, request_body, bearer_token + ) + + # Bearer token should have higher priority. + assert headers == { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(bearer_token), + } + assert request_body == {"foo": "bar"} + + def test_apply_client_authentication_options_bearer_and_request_body(self): + bearer_token = "ACCESS_TOKEN" + headers = {"Content-Type": "application/json"} + request_body = {"foo": "bar"} + auth_handler = self.make_oauth_client_auth_handler( + self.CLIENT_AUTH_REQUEST_BODY + ) + + auth_handler.apply_client_authentication_options( + headers, request_body, bearer_token + ) + + # Bearer token should have higher priority. + assert headers == { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(bearer_token), + } + assert request_body == {"foo": "bar"} + + +def test__handle_error_response_code_only(): + error_resp = {"error": "unsupported_grant_type"} + response_data = json.dumps(error_resp) + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match(r"Error code unsupported_grant_type") + + +def test__handle_error_response_code_description(): + error_resp = { + "error": "unsupported_grant_type", + "error_description": "The provided grant_type is unsupported", + } + response_data = json.dumps(error_resp) + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match( + r"Error code unsupported_grant_type: The provided grant_type is unsupported" + ) + + +def test__handle_error_response_code_description_uri(): + error_resp = { + "error": "unsupported_grant_type", + "error_description": "The provided grant_type is unsupported", + "error_uri": "https://tools.ietf.org/html/rfc6749", + } + response_data = json.dumps(error_resp) + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match( + r"Error code unsupported_grant_type: The provided grant_type is unsupported - https://tools.ietf.org/html/rfc6749" + ) + + +def test__handle_error_response_non_json(): + response_data = "Oops, something wrong happened" + + with pytest.raises(exceptions.OAuthError) as excinfo: + utils.handle_error_response(response_data) + + assert excinfo.match(r"Oops, something wrong happened")