Add google.oauth2.flow - utility for doing OAuth 2.0 Authorization Flow (#100)
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index cd83aa8..b54dd24 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 b045c8c..5dbac87 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 0000000..bae5408
--- /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 adb9403..5dc2406 100644
--- a/docs/reference/google.oauth2.rst
+++ b/docs/reference/google.oauth2.rst
@@ -12,6 +12,7 @@
.. 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 0000000..69e73ff
--- /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 ca3184f..8dc9e71 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 0000000..1baa499
--- /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 0000000..7fc268c
--- /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 ad760bd..0465200 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,6 +11,7 @@
urllib3
certifi
requests
+ requests-oauthlib
oauth2client
grpcio; platform_python_implementation != 'PyPy'
commands =