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..69e73ffd7 --- /dev/null +++ b/google/oauth2/flow.py @@ -0,0 +1,250 @@ +# 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:: + + import google.oauth2.flow + + # Create the flow using the client secrets file from the Google API + # Console. + 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') + + # 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 + +_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id')) + + +class Flow(object): + """OAuth 2.0 Authorization Flow + + 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 + :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.client_type = None + """str: The client type, either ``'web'`` or ``'installed'``""" + + if 'web' in client_config: + self.client_config = client_config['web'] + self.client_type = 'web' + elif 'installed' in client_config: + self.client_config = client_config['installed'] + self.client_type = 'installed' + else: + raise ValueError( + 'Client secrets must be for a web or installed app.') + + 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( + 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 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'], + 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/setup.py b/setup.py index ca3184f88..8dc9e717d 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/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..7fc268ccb --- /dev/null +++ b/tests/oauth2/test_flow.py @@ -0,0 +1,139 @@ +# 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. +# 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_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) + 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 =