From 23a4cef69c3f9b9eb7c84f15533fa7257dde7905 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Fri, 6 Jan 2017 14:15:44 -0800 Subject: [PATCH 1/5] Add google.oauth2.flow - utility for doing OAuth 2.0 Authorization Flow --- docs/_static/custom.css | 4 + docs/conf.py | 2 + docs/reference/google.oauth2.flow.rst | 7 + docs/reference/google.oauth2.rst | 1 + google/oauth2/flow.py | 242 ++++++++++++++++++++++++++ tests/data/client_secrets.json | 14 ++ tests/oauth2/test_flow.py | 134 ++++++++++++++ tox.ini | 1 + 8 files changed, 405 insertions(+) create mode 100644 docs/reference/google.oauth2.flow.rst create mode 100644 google/oauth2/flow.py create mode 100644 tests/data/client_secrets.json create mode 100644 tests/oauth2/test_flow.py diff --git a/docs/_static/custom.css b/docs/_static/custom.css index cd83aa861..b54dd24b0 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,3 +1,7 @@ +div.document { + width: 1040px; +} + code.descname { color: #4885ed; } diff --git a/docs/conf.py b/docs/conf.py index b045c8ca3..5dbac8790 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -370,6 +370,8 @@ 'python': ('https://docs.python.org/3.5', None), 'urllib3': ('https://urllib3.readthedocs.io/en/stable', None), 'requests': ('http://docs.python-requests.org/en/stable', None), + 'requests-oauthlib': ( + 'http://requests-oauthlib.readthedocs.io/en/stable', None), } # Autodoc config diff --git a/docs/reference/google.oauth2.flow.rst b/docs/reference/google.oauth2.flow.rst new file mode 100644 index 000000000..bae54084b --- /dev/null +++ b/docs/reference/google.oauth2.flow.rst @@ -0,0 +1,7 @@ +google.oauth2.flow module +========================= + +.. automodule:: google.oauth2.flow + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.oauth2.rst b/docs/reference/google.oauth2.rst index adb9403ef..5dc24068f 100644 --- a/docs/reference/google.oauth2.rst +++ b/docs/reference/google.oauth2.rst @@ -12,6 +12,7 @@ Submodules .. toctree:: google.oauth2.credentials + google.oauth2.flow google.oauth2.id_token google.oauth2.service_account diff --git a/google/oauth2/flow.py b/google/oauth2/flow.py new file mode 100644 index 000000000..520c03b97 --- /dev/null +++ b/google/oauth2/flow.py @@ -0,0 +1,242 @@ +# 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 Authorization Flow + +This module provides integration with `requests-oauthlib`_ for running the +`OAuth 2.0 Authorization Flow`_ and acquiring user credentials. + +Here's an example of using the flow with the installed application +authorization flow:: + + from google.oauth2 import flow + + # Create the flow using the client secrets file from the Google API + # Console. + flow = flow.Flow.from_client_secrets_file( + 'path/to/client_secrets.json', + scopes=['profile', 'email'], + redirect_uri='urn:ietf:wg:oauth:2.0:oob') + + # Tell the user to go to the authorization URL. + auth_url, _ = flow.authorization_url(prompt='consent') + + print('Please go to this URL: {}'.format(auth_url)) + + # The user will get an authorization code. This code is used to get the + # access token. + code = input('Enter the authorization code: ') + flow.fetch_token(code=code) + + # You can use flow.credentials, or you can just get a requests session + # using flow.authorized_session. + session = flow.authorized_session() + print(session.get('https://www.googleapis.com/userinfo/v2/me').json()) + +.. _requests-oauthlib: http://requests-oauthlib.readthedocs.io/en/stable/ +.. _OAuth 2.0 Authorization Flow: + https://tools.ietf.org/html/rfc6749#section-1.2 +""" + +import json + +import requests_oauthlib + +import google.auth.transport.requests +import google.oauth2.credentials + + +class Flow(object): + """OAuth 2.0 Authorization Flow + + This class is a thin wrapper over :class:`requests_oauthlib.OAuth2Session`. + It provides convenience methods and sane defaults for doing Google's + particular flavors of OAuth 2.0. + + Typically you'll construct an instance of this flow using + :meth:`from_client_secrets_file` and a `client secrets file`_ obtained + from the `Google API Console`_. + + .. _client secrets file: + https://developers.google.com/identity/protocols/OAuth2WebServer + #creatingcred + .. _Google API Console: + https://console.developers.google.com/apis/credentials + """ + + def __init__(self, client_config, scopes, **kwargs): + """ + Args: + client_config (Mapping[str, Any]): The client + configuration in the Google `client secrets`_ format. + scopes (Sequence[str]): The list of scopes to request during the + flow. + kwargs: Any additional parameters passed to + :class:`requests_oauthlib.OAuth2Session` + + Raises: + ValueError: If the client configuration is not in the correct + format. + + .. _client secrets: + https://developers.google.com/api-client-library/python/guide + /aaa_client_secrets + """ + self.client_config = None + """Mapping[str, Any]: The OAuth 2.0 client configuration.""" + self.type = None + """str: The client type, either ``'web'`` or ``'installed'``""" + + if 'web' in client_config: + self.client_config = client_config['web'] + self.type = 'web' + elif 'installed' in client_config: + self.client_config = client_config['installed'] + self.type = 'installed' + else: + raise ValueError( + 'Client secrets must be for a web or installed app.') + + self.oauth2session = requests_oauthlib.OAuth2Session( + client_id=self.client_config['client_id'], + scope=scopes, + **kwargs) + """requests_oauthlib.OAuth2Session: The OAuth 2.0 session.""" + + @classmethod + def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs): + """Creates a :class:`Flow` instance from a Google client secrets file. + + Args: + client_secrets_file (str): The path to the client secrets .json + file. + scopes (Sequence[str]): The list of scopes to request during the + flow. + kwargs: Any additional parameters passed to + :class:`requests_oauthlib.OAuth2Session` + + Returns: + Flow: The constructed Flow instance. + """ + with open(client_secrets_file, 'r') as json_file: + client_config = json.load(json_file) + + return cls(client_config, scopes=scopes, **kwargs) + + @property + def redirect_uri(self): + """The OAuth 2.0 redirect URI. Pass-through to + ``self.oauth2session.redirect_uri``.""" + return self.oauth2session.redirect_uri + + @redirect_uri.setter + def redirect_uri(self, value): + self.oauth2session.redirect_uri = value + + def authorization_url(self, **kwargs): + """Generates an authorization URL. + + This is the first step in the OAuth 2.0 Authorization Flow. The user's + browser should be redirected to the returned URL. + + This method calls + :meth:`requests_oauthlib.OAuth2Session.authorization_url` + and specifies the client configuration's authorization URI (usually + Google's authorization server) and specifies that "offline" access is + desired. This is required in order to obtain a refresh token. + + Args: + kwargs: Additional arguments passed through to + :meth:`requests_oauthlib.OAuth2Session.authorization_url` + + Returns: + Tuple[str, str]: The generated authorization URL and state. The + user must visit the URL to complete the flow. The state is used + when completing the flow to verify that the request originated + from your application. If your application is using a different + :class:`Flow` instance to obtain the token, you will need to + specify the ``state`` when constructing the :class:`Flow`. + """ + url, state = self.oauth2session.authorization_url( + self.client_config['auth_uri'], + access_type='offline', **kwargs) + + return url, state + + def fetch_token(self, **kwargs): + """Completes the Authorization Flow and obtains an access token. + + This is the final step in the OAuth 2.0 Authorization Flow. This is + called after the user consents. + + This method calls + :meth:`requests_oauthlib.OAuth2Session.fetch_token` + and specifies the client configuration's token URI (usually Google's + token server). + + Args: + kwargs: Arguments passed through to + :meth:`requests_oauthlib.OAuth2Session.fetch_token`. At least + one of ``code`` or ``authorization_response`` must be + specified. + + Returns: + Mapping[str, str]: The obtained tokens. Typically, you will not use + return value and use :meth:`credentials`. + """ + return self.oauth2session.fetch_token( + self.client_config['token_uri'], + client_secret=self.client_config['client_secret'], + **kwargs) + + @property + def credentials(self): + """Returns credentials from the OAuth 2.0 session. + + :meth:`fetch_token` must be called before accessing this. This method + constructs a :class:`google.oauth2.credentials.Credentials` class using + the session's token and the client config. + + Returns: + google.oauth2.credentials.Credentials: The constructed credentials. + + Raises: + ValueError: If there is no access token in the session. + """ + if not self.oauth2session.token: + raise ValueError( + 'There is no access token for this session, did you call ' + 'fetch_token?') + + return google.oauth2.credentials.Credentials( + self.oauth2session.token['access_token'], + refresh_token=self.oauth2session.token['refresh_token'], + token_uri=self.client_config['token_uri'], + client_id=self.client_config['client_id'], + client_secret=self.client_config['client_secret'], + scopes=self.oauth2session.scope) + + def authorized_session(self): + """Returns a :class:`requests.Session` authorized with credentials. + + :meth:`fetch_token` must be called before this method. This method + constructs a :class:`google.auth.transport.requests.AuthorizedSession` + class using this flow's :attr:`credentials`. + + Returns: + google.auth.transport.requests.AuthorizedSession: The constructed + session. + """ + return google.auth.transport.requests.AuthorizedSession( + self.credentials) diff --git a/tests/data/client_secrets.json b/tests/data/client_secrets.json new file mode 100644 index 000000000..1baa4995a --- /dev/null +++ b/tests/data/client_secrets.json @@ -0,0 +1,14 @@ +{ + "web": { + "client_id": "example.apps.googleusercontent.com", + "project_id": "example", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "itsasecrettoeveryone", + "redirect_uris": [ + "urn:ietf:wg:oauth:2.0:oob", + "http://localhost" + ] + } +} diff --git a/tests/oauth2/test_flow.py b/tests/oauth2/test_flow.py new file mode 100644 index 000000000..68eff0be3 --- /dev/null +++ b/tests/oauth2/test_flow.py @@ -0,0 +1,134 @@ +# Copyright 2014 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 json +import os + +import mock +import pytest + +from google.oauth2 import flow + +DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data') +CLIENT_SECRETS_FILE = os.path.join(DATA_DIR, 'client_secrets.json') + +with open(CLIENT_SECRETS_FILE, 'r') as fh: + CLIENT_SECRETS_INFO = json.load(fh) + + +def test_constructor_web(): + instance = flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + assert instance.client_config == CLIENT_SECRETS_INFO['web'] + assert (instance.oauth2session.client_id == + CLIENT_SECRETS_INFO['web']['client_id']) + assert instance.oauth2session.scope == mock.sentinel.scopes + + +def test_constructor_installed(): + info = {'installed': CLIENT_SECRETS_INFO['web']} + instance = flow.Flow(info, scopes=mock.sentinel.scopes) + assert instance.client_config == info['installed'] + assert instance.oauth2session.client_id == info['installed']['client_id'] + assert instance.oauth2session.scope == mock.sentinel.scopes + + +def test_constructor_bad(): + with pytest.raises(ValueError): + flow.Flow({}, scopes=[]) + + +def test_from_client_secrets_file(): + instance = flow.Flow.from_client_secrets_file( + CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes) + assert instance.client_config == CLIENT_SECRETS_INFO['web'] + assert (instance.oauth2session.client_id == + CLIENT_SECRETS_INFO['web']['client_id']) + assert instance.oauth2session.scope == mock.sentinel.scopes + + +@pytest.fixture +def instance(): + yield flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + + +def test_redirect_uri(instance): + instance.redirect_uri = mock.sentinel.redirect_uri + assert (instance.redirect_uri == + instance.oauth2session.redirect_uri == + mock.sentinel.redirect_uri) + + +def test_authorization_url(instance): + scope = 'scope_one' + instance.oauth2session.scope = scope + authorization_url_patch = mock.patch.object( + instance.oauth2session, 'authorization_url', + wraps=instance.oauth2session.authorization_url) + + with authorization_url_patch as authorization_url_spy: + url, _ = instance.authorization_url(prompt='consent') + + assert CLIENT_SECRETS_INFO['web']['auth_uri'] in url + assert scope in url + authorization_url_spy.assert_called_with( + CLIENT_SECRETS_INFO['web']['auth_uri'], + access_type='offline', + prompt='consent') + + +def test_fetch_token(instance): + fetch_token_patch = mock.patch.object( + instance.oauth2session, 'fetch_token', autospec=True, + return_value=mock.sentinel.token) + + with fetch_token_patch as fetch_token_mock: + token = instance.fetch_token(code=mock.sentinel.code) + + assert token == mock.sentinel.token + fetch_token_mock.assert_called_with( + CLIENT_SECRETS_INFO['web']['token_uri'], + client_secret=CLIENT_SECRETS_INFO['web']['client_secret'], + code=mock.sentinel.code) + + +def test_credentials(instance): + instance.oauth2session.token = { + 'access_token': mock.sentinel.access_token, + 'refresh_token': mock.sentinel.refresh_token + } + + credentials = instance.credentials + + assert credentials.token == mock.sentinel.access_token + assert credentials._refresh_token == mock.sentinel.refresh_token + assert credentials._client_id == CLIENT_SECRETS_INFO['web']['client_id'] + assert (credentials._client_secret == + CLIENT_SECRETS_INFO['web']['client_secret']) + assert credentials._token_uri == CLIENT_SECRETS_INFO['web']['token_uri'] + + +def test_bad_credentials(instance): + with pytest.raises(ValueError): + assert instance.credentials + + +def test_authorized_session(instance): + instance.oauth2session.token = { + 'access_token': mock.sentinel.access_token, + 'refresh_token': mock.sentinel.refresh_token + } + + session = instance.authorized_session() + + assert session.credentials.token == mock.sentinel.access_token diff --git a/tox.ini b/tox.ini index ad760bd66..046520059 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ deps = urllib3 certifi requests + requests-oauthlib oauth2client grpcio; platform_python_implementation != 'PyPy' commands = From 71101883df0b5fcb6879c77d946b3c31062fe8d8 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Fri, 6 Jan 2017 14:20:35 -0800 Subject: [PATCH 2/5] Fix tests --- tests/oauth2/test_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/oauth2/test_flow.py b/tests/oauth2/test_flow.py index 68eff0be3..c83a50e9e 100644 --- a/tests/oauth2/test_flow.py +++ b/tests/oauth2/test_flow.py @@ -71,7 +71,7 @@ def test_redirect_uri(instance): def test_authorization_url(instance): scope = 'scope_one' - instance.oauth2session.scope = scope + instance.oauth2session.scope = [scope] authorization_url_patch = mock.patch.object( instance.oauth2session, 'authorization_url', wraps=instance.oauth2session.authorization_url) From 62be74a1c2410fc377c7f649134bbdebb0c763ae Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Tue, 10 Jan 2017 12:50:28 -0800 Subject: [PATCH 3/5] Address review comments --- google/oauth2/flow.py | 24 ++++++++++++++++-------- setup.py | 7 +++++++ tests/oauth2/test_flow.py | 9 +++++++-- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/google/oauth2/flow.py b/google/oauth2/flow.py index 520c03b97..eee9a45d7 100644 --- a/google/oauth2/flow.py +++ b/google/oauth2/flow.py @@ -20,11 +20,11 @@ Here's an example of using the flow with the installed application authorization flow:: - from google.oauth2 import flow + import google.oauth2.flow # Create the flow using the client secrets file from the Google API # Console. - flow = flow.Flow.from_client_secrets_file( + flow = google.oauth2.flow.Flow.from_client_secrets_file( 'path/to/client_secrets.json', scopes=['profile', 'email'], redirect_uri='urn:ietf:wg:oauth:2.0:oob') @@ -60,8 +60,9 @@ class Flow(object): """OAuth 2.0 Authorization Flow - This class is a thin wrapper over :class:`requests_oauthlib.OAuth2Session`. - It provides convenience methods and sane defaults for doing Google's + This class uses a :class:`requests_oauthlib.OAuth2Session` instance at + :attr:`oauth2session` to perform all of the OAuth 2.0 logic. This class + just provides convenience methods and sane defaults for doing Google's particular flavors of OAuth 2.0. Typically you'll construct an instance of this flow using @@ -95,19 +96,24 @@ def __init__(self, client_config, scopes, **kwargs): """ self.client_config = None """Mapping[str, Any]: The OAuth 2.0 client configuration.""" - self.type = None + self.client_type = None """str: The client type, either ``'web'`` or ``'installed'``""" if 'web' in client_config: self.client_config = client_config['web'] - self.type = 'web' + self.client_type = 'web' elif 'installed' in client_config: self.client_config = client_config['installed'] - self.type = 'installed' + self.client_type = 'installed' else: raise ValueError( 'Client secrets must be for a web or installed app.') + required_keys = ('auth_uri', 'token_uri', 'client_id') + + if not set(required_keys).issubset(self.client_config.keys()): + raise ValueError('Client secrets is not in the correct format.') + self.oauth2session = requests_oauthlib.OAuth2Session( client_id=self.client_config['client_id'], scope=scopes, @@ -193,7 +199,9 @@ def fetch_token(self, **kwargs): Returns: Mapping[str, str]: The obtained tokens. Typically, you will not use - return value and use :meth:`credentials`. + return value of this function and instead and use + :meth:`credentials` to obtain a + :class:`~google.auth.credentials.Credentials` instance. """ return self.oauth2session.fetch_token( self.client_config['token_uri'], diff --git a/setup.py b/setup.py index ca3184f88..838d155c2 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,10 @@ 'six>=1.9.0', ) +EXTRA_OAUTHLIB_DEPENDENCIES = ( + 'requests-oauthlib>=0.7.0', +) + with open('README.rst', 'r') as fh: long_description = fh.read() @@ -38,6 +42,9 @@ packages=find_packages(exclude=('tests', 'system_tests')), namespace_packages=('google',), install_requires=DEPENDENCIES, + extras_require={ + 'oauthlib': EXTRA_OAUTHLIB_DEPENDENCIES + }, license='Apache 2.0', keywords='google auth oauth client', classifiers=( diff --git a/tests/oauth2/test_flow.py b/tests/oauth2/test_flow.py index c83a50e9e..d8cde947a 100644 --- a/tests/oauth2/test_flow.py +++ b/tests/oauth2/test_flow.py @@ -1,4 +1,4 @@ -# Copyright 2014 Google Inc. +# 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. @@ -43,11 +43,16 @@ def test_constructor_installed(): assert instance.oauth2session.scope == mock.sentinel.scopes -def test_constructor_bad(): +def test_constructor_bad_format(): with pytest.raises(ValueError): flow.Flow({}, scopes=[]) +def test_constructor_missing_keys(): + with pytest.raises(ValueError): + flow.Flow({'web': {}}, scopes=[]) + + def test_from_client_secrets_file(): instance = flow.Flow.from_client_secrets_file( CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes) From d9cd1475027db8ab6223c37487d657b2a32cc765 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Tue, 10 Jan 2017 13:26:51 -0800 Subject: [PATCH 4/5] Address review comments --- google/oauth2/flow.py | 6 +++--- setup.py | 2 +- tests/oauth2/test_flow.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/google/oauth2/flow.py b/google/oauth2/flow.py index eee9a45d7..c5b4ebd9e 100644 --- a/google/oauth2/flow.py +++ b/google/oauth2/flow.py @@ -56,6 +56,8 @@ import google.auth.transport.requests import google.oauth2.credentials +_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id')) + class Flow(object): """OAuth 2.0 Authorization Flow @@ -109,9 +111,7 @@ def __init__(self, client_config, scopes, **kwargs): raise ValueError( 'Client secrets must be for a web or installed app.') - required_keys = ('auth_uri', 'token_uri', 'client_id') - - if not set(required_keys).issubset(self.client_config.keys()): + if not set(_REQUIRED_CONFIG_KEYS).issubset(self.client_config.keys()): raise ValueError('Client secrets is not in the correct format.') self.oauth2session = requests_oauthlib.OAuth2Session( diff --git a/setup.py b/setup.py index 838d155c2..8dc9e717d 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ namespace_packages=('google',), install_requires=DEPENDENCIES, extras_require={ - 'oauthlib': EXTRA_OAUTHLIB_DEPENDENCIES + 'oauthlib': EXTRA_OAUTHLIB_DEPENDENCIES, }, license='Apache 2.0', keywords='google auth oauth client', diff --git a/tests/oauth2/test_flow.py b/tests/oauth2/test_flow.py index d8cde947a..7fc268ccb 100644 --- a/tests/oauth2/test_flow.py +++ b/tests/oauth2/test_flow.py @@ -1,4 +1,4 @@ -# Copyright 2016 Google Inc. +# Copyright 2017 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From a75f056271d3a58803413adc5023f44344895ee4 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Tue, 10 Jan 2017 13:30:31 -0800 Subject: [PATCH 5/5] Address review comments --- google/oauth2/flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/oauth2/flow.py b/google/oauth2/flow.py index c5b4ebd9e..69e73ffd7 100644 --- a/google/oauth2/flow.py +++ b/google/oauth2/flow.py @@ -111,7 +111,7 @@ def __init__(self, client_config, scopes, **kwargs): raise ValueError( 'Client secrets must be for a web or installed app.') - if not set(_REQUIRED_CONFIG_KEYS).issubset(self.client_config.keys()): + if not _REQUIRED_CONFIG_KEYS.issubset(self.client_config.keys()): raise ValueError('Client secrets is not in the correct format.') self.oauth2session = requests_oauthlib.OAuth2Session(