From 780b5bd1fd12e31f9963fee3dfb9a4bcddad0396 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Fri, 7 Oct 2016 14:22:55 -0700 Subject: [PATCH 1/2] Add google.oauth2._client --- google/oauth2/_client.py | 193 +++++++++++++++++++++++++++++++++++ tests/oauth2/__init__.py | 0 tests/oauth2/test__client.py | 139 +++++++++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 google/oauth2/_client.py create mode 100644 tests/oauth2/__init__.py create mode 100644 tests/oauth2/test__client.py diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py new file mode 100644 index 000000000..6db090a21 --- /dev/null +++ b/google/oauth2/_client.py @@ -0,0 +1,193 @@ +# Copyright 2016 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. + +"""OAuth 2.0 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 + +from six.moves import http_client +from six.moves import urllib + +from google.auth import _helpers +from google.auth import exceptions + +_JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer' +_REFRESH_GRANT_TYPE = 'refresh_token' + + +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 = ': '.join([ + error_data['error'], + error_data.get('error_description')]) + # If not details could be extracted, use the response data. + except (KeyError, ValueError): + error_details = response_body + + raise exceptions.RefreshError(error_details) + + +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: + return _helpers.utcnow() + datetime.timedelta( + seconds=expires_in) + else: + return None + + +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. + google.auth.exceptions.TransportError: If there was an error connecting + to the token endpoint. + """ + body = urllib.parse.urlencode(body) + headers = { + 'content-type': 'application/x-www-form-urlencoded', + } + + response = request( + method='POST', url=token_uri, headers=headers, body=body) + + response_body = response.data.decode('utf-8') + + if response.status != http_client.OK: + _handle_error_response(response_body) + + response_data = json.loads(response_body) + + return response_data + + +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. + google.auth.exceptions.TransportError: If there was an error connecting + to the token endpoint. + + .. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4 + """ + body = { + 'assertion': assertion, + 'grant_type': _JWT_GRANT_TYPE, + } + + response_data = _token_endpoint_request(request, token_uri, body) + + access_token = response_data['access_token'] + expiry = _parse_expiry(response_data) + + return access_token, expiry, response_data + + +def refresh_grant(request, token_uri, refresh_token, client_id, client_secret): + """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. + + 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. + google.auth.exceptions.TransportError: If there was an error connecting + to the token endpoint. + + .. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6 + """ + body = { + 'grant_type': _REFRESH_GRANT_TYPE, + 'client_id': client_id, + 'client_secret': client_secret, + 'refresh_token': refresh_token, + } + + response_data = _token_endpoint_request(request, token_uri, body) + + access_token = response_data['access_token'] + 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/tests/oauth2/__init__.py b/tests/oauth2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py new file mode 100644 index 000000000..9ac8de692 --- /dev/null +++ b/tests/oauth2/test__client.py @@ -0,0 +1,139 @@ +# Copyright 2016 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 six.moves import http_client +from six.moves import urllib + +from google.auth import exceptions +from google.oauth2 import _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(now_mock): + 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 test__token_endpoint_request(): + response = mock.Mock() + response.status = http_client.OK + response.data = json.dumps({'test': 'response'}).encode('utf-8') + request = mock.Mock(return_value=response) + + result = _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') + + # Check result + assert result == {'test': 'response'} + + +def test__token_endpoint_request_error(): + response = mock.Mock() + response.status = http_client.BAD_REQUEST + response.data = b'Error' + request = mock.Mock(return_value=response) + + with pytest.raises(exceptions.RefreshError): + _client._token_endpoint_request(request, 'http://example.com', {}) + + +@mock.patch('google.auth._helpers.utcnow', return_value=datetime.datetime.min) +def test_jwt_grant(now_mock): + response = mock.Mock() + response.status = http_client.OK + response.data = json.dumps({ + 'access_token': 'token', + 'expires_in': 500, + 'extra': 'data'}).encode('utf-8') + request = mock.Mock(return_value=response) + + token, expiry, extra_data = _client.jwt_grant( + request, 'http://example.com', 'assertion') + + # Check request call + request_body = request.call_args[1]['body'] + request_params = urllib.parse.parse_qs(request_body) + assert request_params['grant_type'][0] == _client._JWT_GRANT_TYPE + assert request_params['assertion'][0] == 'assertion' + + # Check result + assert token == '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) +def test_refresh_grant(now_mock): + response = mock.Mock() + response.status = http_client.OK + response.data = json.dumps({ + 'access_token': 'token', + 'refresh_token': 'new_refresh_token', + 'expires_in': 500, + 'extra': 'data'}).encode('utf-8') + request = mock.Mock(return_value=response) + + token, refresh_token, expiry, extra_data = _client.refresh_grant( + request, 'http://example.com', 'refresh_token', 'client_id', + 'client_secret') + + # Check request call + request_body = request.call_args[1]['body'] + request_params = urllib.parse.parse_qs(request_body) + assert request_params['grant_type'][0] == _client._REFRESH_GRANT_TYPE + assert request_params['refresh_token'][0] == 'refresh_token' + assert request_params['client_id'][0] == 'client_id' + assert request_params['client_secret'][0] == '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' From f01db6b5be0d027998369aa36d3f4c88d11ae66d Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Fri, 7 Oct 2016 15:26:43 -0700 Subject: [PATCH 2/2] Address review comments --- google/oauth2/_client.py | 31 ++++++++------ tests/oauth2/test__client.py | 78 +++++++++++++++++++++++++----------- 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py index 6db090a21..1b26549b7 100644 --- a/google/oauth2/_client.py +++ b/google/oauth2/_client.py @@ -32,6 +32,7 @@ from google.auth import _helpers from google.auth import exceptions +_URLENCODED_CONTENT_TYPE = 'application/x-www-form-urlencoded' _JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer' _REFRESH_GRANT_TYPE = 'refresh_token' @@ -50,11 +51,12 @@ def _handle_error_response(response_body): error_details = ': '.join([ error_data['error'], error_data.get('error_description')]) - # If not details could be extracted, use the response data. + # If no details could be extracted, use the response data. except (KeyError, ValueError): error_details = response_body - raise exceptions.RefreshError(error_details) + raise exceptions.RefreshError( + error_details, response_body) def _parse_expiry(response_data): @@ -68,7 +70,8 @@ def _parse_expiry(response_data): specified. """ expires_in = response_data.get('expires_in', None) - if expires_in: + + if expires_in is not None: return _helpers.utcnow() + datetime.timedelta( seconds=expires_in) else: @@ -91,12 +94,10 @@ def _token_endpoint_request(request, token_uri, body): Raises: google.auth.exceptions.RefreshError: If the token endpoint returned an error. - google.auth.exceptions.TransportError: If there was an error connecting - to the token endpoint. """ body = urllib.parse.urlencode(body) headers = { - 'content-type': 'application/x-www-form-urlencoded', + 'content-type': _URLENCODED_CONTENT_TYPE, } response = request( @@ -131,8 +132,6 @@ def jwt_grant(request, token_uri, assertion): Raises: google.auth.exceptions.RefreshError: If the token endpoint returned an error. - google.auth.exceptions.TransportError: If there was an error connecting - to the token endpoint. .. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4 """ @@ -143,7 +142,12 @@ def jwt_grant(request, token_uri, assertion): response_data = _token_endpoint_request(request, token_uri, body) - access_token = response_data['access_token'] + try: + access_token = response_data['access_token'] + except KeyError: + raise exceptions.RefreshError( + 'No access token in response.', response_data) + expiry = _parse_expiry(response_data) return access_token, expiry, response_data @@ -172,8 +176,6 @@ def refresh_grant(request, token_uri, refresh_token, client_id, client_secret): Raises: google.auth.exceptions.RefreshError: If the token endpoint returned an error. - google.auth.exceptions.TransportError: If there was an error connecting - to the token endpoint. .. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6 """ @@ -186,7 +188,12 @@ def refresh_grant(request, token_uri, refresh_token, client_id, client_secret): response_data = _token_endpoint_request(request, token_uri, body) - access_token = response_data['access_token'] + try: + access_token = response_data['access_token'] + except KeyError: + raise exceptions.RefreshError( + 'No access token in response.', response_data) + refresh_token = response_data.get('refresh_token', refresh_token) expiry = _parse_expiry(response_data) diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py index 9ac8de692..8c19c3ee9 100644 --- a/tests/oauth2/test__client.py +++ b/tests/oauth2/test__client.py @@ -17,6 +17,7 @@ import mock import pytest +import six from six.moves import http_client from six.moves import urllib @@ -54,11 +55,15 @@ def test__parse_expiry_none(): assert _client._parse_expiry({}) is None -def test__token_endpoint_request(): +def _make_request(response_data): response = mock.Mock() response.status = http_client.OK - response.data = json.dumps({'test': 'response'}).encode('utf-8') - request = mock.Mock(return_value=response) + response.data = json.dumps(response_data).encode('utf-8') + return mock.Mock(return_value=response) + + +def test__token_endpoint_request(): + request = _make_request({'test': 'response'}) result = _client._token_endpoint_request( request, 'http://example.com', {'test': 'params'}) @@ -84,24 +89,29 @@ def test__token_endpoint_request_error(): _client._token_endpoint_request(request, 'http://example.com', {}) +def _verify_request_params(request, params): + request_body = request.call_args[1]['body'] + 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) def test_jwt_grant(now_mock): - response = mock.Mock() - response.status = http_client.OK - response.data = json.dumps({ + request = _make_request({ 'access_token': 'token', 'expires_in': 500, - 'extra': 'data'}).encode('utf-8') - request = mock.Mock(return_value=response) + 'extra': 'data'}) token, expiry, extra_data = _client.jwt_grant( - request, 'http://example.com', 'assertion') + request, 'http://example.com', 'assertion_value') # Check request call - request_body = request.call_args[1]['body'] - request_params = urllib.parse.parse_qs(request_body) - assert request_params['grant_type'][0] == _client._JWT_GRANT_TYPE - assert request_params['assertion'][0] == 'assertion' + _verify_request_params(request, { + 'grant_type': _client._JWT_GRANT_TYPE, + 'assertion': 'assertion_value' + }) # Check result assert token == 'token' @@ -109,31 +119,51 @@ def test_jwt_grant(now_mock): assert extra_data['extra'] == 'data' +def test_jwt_grant_no_access_token(): + request = _make_request({ + # No access token. + 'expires_in': 500, + 'extra': 'data'}) + + with pytest.raises(exceptions.RefreshError): + _client.jwt_grant(request, 'http://example.com', 'assertion_value') + + @mock.patch('google.auth._helpers.utcnow', return_value=datetime.datetime.min) def test_refresh_grant(now_mock): - response = mock.Mock() - response.status = http_client.OK - response.data = json.dumps({ + request = _make_request({ 'access_token': 'token', 'refresh_token': 'new_refresh_token', 'expires_in': 500, - 'extra': 'data'}).encode('utf-8') - request = mock.Mock(return_value=response) + 'extra': 'data'}) token, refresh_token, expiry, extra_data = _client.refresh_grant( request, 'http://example.com', 'refresh_token', 'client_id', 'client_secret') # Check request call - request_body = request.call_args[1]['body'] - request_params = urllib.parse.parse_qs(request_body) - assert request_params['grant_type'][0] == _client._REFRESH_GRANT_TYPE - assert request_params['refresh_token'][0] == 'refresh_token' - assert request_params['client_id'][0] == 'client_id' - assert request_params['client_secret'][0] == 'client_secret' + _verify_request_params(request, { + 'grant_type': _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' + + +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): + _client.refresh_grant( + request, 'http://example.com', 'refresh_token', 'client_id', + 'client_secret')