Repository: googleapis/google-auth-library-python

Issue:
Explore integration with oauthlib



Discussion:
Working proof-of-concept:

``` python
import json

from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import credentials
import requests_oauthlib


client_config = json.load(open('client-secrets.json', 'r'))['installed']

scopes = [
    'https://www.googleapis.com/auth/userinfo.email',
    'https://www.googleapis.com/auth/userinfo.profile']


oauth = requests_oauthlib.OAuth2Session(
    client_config['client_id'],
    redirect_uri='http://localhost',
    scope=scopes)


authorization_url, state = oauth.authorization_url(
    client_config['auth_uri'],
    access_type='offline', approval_prompt='force')

print(authorization_url)

authorization_response = input('Enter the callback URL')

token = oauth.fetch_token(
    client_config['token_uri'],
    authorization_response=authorization_response,
    client_secret=client_config['client_secret'])


credentials = credentials.Credentials(
    oauth.token['access_token'],
    refresh_token=oauth.token['refresh_token'],
    token_uri=client_config['token_uri'],
    client_id=client_config['client_id'],
    client_secret=client_config['client_secret'],
    scopes=oauth.scope)


session = AuthorizedSession(credentials)

response = session.get('https://www.googleapis.com/userinfo/v2/me')
print(response.json())

```


Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- 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/google/oauth2/flow.py b/google/oauth2/flow.py
new file mode 100644
--- /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
--- 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=(


Test patch:
diff --git a/tests/data/client_secrets.json b/tests/data/client_secrets.json
new file mode 100644
--- /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
--- /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