oauth2decorator, reviewed in http://codereview.appspot.com/4524063/
diff --git a/README b/README
index 8ace1c2..c68d441 100644
--- a/README
+++ b/README
@@ -42,3 +42,7 @@
Depending on your version of Python, these libraries may also be installed:
http://pypi.python.org/pypi/simplejson/
+
+For developement you will also need:
+
+http://pythonpaste.org/webtest/
diff --git a/oauth2client/appengine.py b/oauth2client/appengine.py
index f939bc0..5600387 100644
--- a/oauth2client/appengine.py
+++ b/oauth2client/appengine.py
@@ -19,13 +19,22 @@
__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+import httplib2
import pickle
-from google.appengine.ext import db
+from client import AccessTokenRefreshError
from client import Credentials
from client import Flow
+from client import OAuth2WebServerFlow
from client import Storage
+from google.appengine.api import memcache
+from google.appengine.api import users
+from google.appengine.ext import db
+from google.appengine.ext import webapp
+from google.appengine.ext.webapp.util import login_required
+from google.appengine.ext.webapp.util import run_wsgi_app
+OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns'
class FlowProperty(db.Property):
"""App Engine datastore Property for Flow.
@@ -102,17 +111,19 @@
are stored by key_name.
"""
- def __init__(self, model, key_name, property_name):
+ def __init__(self, model, key_name, property_name, cache=None):
"""Constructor for Storage.
Args:
model: db.Model, model class
key_name: string, key name for the entity that has the credentials
property_name: string, name of the property that is an CredentialsProperty
+ cache: memcache, a write-through cache to put in front of the datastore
"""
self._model = model
self._key_name = key_name
self._property_name = property_name
+ self._cache = cache
def get(self):
"""Retrieve Credential from datastore.
@@ -120,10 +131,17 @@
Returns:
oauth2client.Credentials
"""
+ if self._cache:
+ credential = self._cache.get(self._key_name)
+ if credential:
+ return pickle.loads(credential)
entity = self._model.get_or_insert(self._key_name)
credential = getattr(entity, self._property_name)
if credential and hasattr(credential, 'set_store'):
credential.set_store(self.put)
+ if self._cache:
+ self._cache.set(self._key_name, pickle.dumps(credentials))
+
return credential
def put(self, credentials):
@@ -135,3 +153,174 @@
entity = self._model.get_or_insert(self._key_name)
setattr(entity, self._property_name, credentials)
entity.put()
+ if self._cache:
+ self._cache.set(self._key_name, pickle.dumps(credentials))
+
+
+class CredentialsModel(db.Model):
+ """Storage for OAuth 2.0 Credentials
+
+ Storage of the model is keyed by the user.user_id().
+ """
+ credentials = CredentialsProperty()
+
+
+class OAuth2Decorator(object):
+ """Utility for making OAuth 2.0 easier.
+
+ Instantiate and then use with oauth_required or oauth_aware
+ as decorators on webapp.RequestHandler methods.
+
+ Example:
+
+ decorator = OAuth2Decorator(
+ client_id='837...ent.com',
+ client_secret='Qh...wwI',
+ scope='https://www.googleapis.com/auth/buzz',
+ user_agent='my-sample-app/1.0')
+
+
+ class MainHandler(webapp.RequestHandler):
+
+ @decorator.oauth_required
+ def get(self):
+ http = decorator.http()
+ # http is authorized with the user's Credentials and can be used
+ # in API calls
+
+ """
+ def __init__(self, client_id, client_secret, scope, user_agent,
+ auth_uri='https://accounts.google.com/o/oauth2/auth',
+ token_uri='https://accounts.google.com/o/oauth2/token'):
+
+ """Constructor for OAuth2Decorator
+
+ Args:
+ client_id: string, client identifier.
+ client_secret: string client secret.
+ scope: string, scope of the credentials being requested.
+ user_agent: string, HTTP User-Agent to provide for this application.
+ auth_uri: string, URI for authorization endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0 provider can be used.
+ token_uri: string, URI for token endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0 provider can be used.
+ """
+ self.flow = OAuth2WebServerFlow(client_id, client_secret, scope, user_agent,
+ auth_uri, token_uri)
+ self.credentials = None
+ self._request_handler = None
+
+ def oauth_required(self, method):
+ """Decorator that starts the OAuth 2.0 dance.
+
+ Starts the OAuth dance for the logged in user if they haven't already
+ granted access for this application.
+
+ Args:
+ method: callable, to be decorated method of a webapp.RequestHandler
+ instance.
+ """
+ @login_required
+ def check_oauth(request_handler, *args):
+ # Store the request URI in 'state' so we can use it later
+ self.flow.params['state'] = request_handler.request.url
+ self._request_handler = request_handler
+ user = users.get_current_user()
+ self.credentials = StorageByKeyName(
+ CredentialsModel, user.user_id(), 'credentials').get()
+
+ if not self.has_credentials():
+ return request_handler.redirect(self.authorize_url())
+ try:
+ method(request_handler, *args)
+ except AccessTokenRefreshError:
+ return request_handler.redirect(self.authorize_url())
+
+ return check_oauth
+
+ def oauth_aware(self, method):
+ """Decorator that sets up for OAuth 2.0 dance, but doesn't do it.
+
+ Does all the setup for the OAuth dance, but doesn't initiate it.
+ This decorator is useful if you want to create a page that knows
+ whether or not the user has granted access to this application.
+ From within a method decorated with @oauth_aware the has_credentials()
+ and authorize_url() methods can be called.
+
+ Args:
+ method: callable, to be decorated method of a webapp.RequestHandler
+ instance.
+ """
+ @login_required
+ def setup_oauth(request_handler, *args):
+ self.flow.params['state'] = request_handler.request.url
+ self._request_handler = request_handler
+ user = users.get_current_user()
+ self.credentials = StorageByKeyName(
+ CredentialsModel, user.user_id(), 'credentials').get()
+ method(request_handler, *args)
+ return setup_oauth
+
+ def has_credentials(self):
+ """True if for the logged in user there are valid access Credentials.
+
+ Must only be called from with a webapp.RequestHandler subclassed method
+ that had been decorated with either @oauth_required or @oauth_aware.
+ """
+ return self.credentials is not None and not self.credentials.invalid
+
+ def authorize_url(self):
+ """Returns the URL to start the OAuth dance.
+
+ Must only be called from with a webapp.RequestHandler subclassed method
+ that had been decorated with either @oauth_required or @oauth_aware.
+ """
+ callback = self._request_handler.request.relative_url('/oauth2callback')
+ url = self.flow.step1_get_authorize_url(callback)
+ user = users.get_current_user()
+ memcache.set(user.user_id(), pickle.dumps(self.flow),
+ namespace=OAUTH2CLIENT_NAMESPACE)
+ return url
+
+ def http(self):
+ """Returns an authorized http instance.
+
+ Must only be called from within an @oauth_required decorated method, or
+ from within an @oauth_aware decorated method where has_credentials()
+ returns True.
+ """
+ return self.credentials.authorize(httplib2.Http())
+
+
+class OAuth2Handler(webapp.RequestHandler):
+ """Handler for the redirect_uri of the OAuth 2.0 dance."""
+
+ @login_required
+ def get(self):
+ error = self.request.get('error')
+ if error:
+ errormsg = self.request.get('error_description', error)
+ self.response.out.write('The authorization request failed: %s' % errormsg)
+ else:
+ user = users.get_current_user()
+ flow = pickle.loads(memcache.get(user.user_id(), namespace=OAUTH2CLIENT_NAMESPACE))
+ # This code should be ammended with application specific error
+ # handling. The following cases should be considered:
+ # 1. What if the flow doesn't exist in memcache? Or is corrupt?
+ # 2. What if the step2_exchange fails?
+ if flow:
+ credentials = flow.step2_exchange(self.request.params)
+ StorageByKeyName(
+ CredentialsModel, user.user_id(), 'credentials').put(credentials)
+ self.redirect(self.request.get('state'))
+ else:
+ # TODO Add error handling here.
+ pass
+
+
+application = webapp.WSGIApplication([('/oauth2callback', OAuth2Handler)])
+
+def main():
+ run_wsgi_app(application)
+
+#
diff --git a/runtests.py b/runtests.py
index d331e01..a09a6c0 100644
--- a/runtests.py
+++ b/runtests.py
@@ -6,6 +6,8 @@
import unittest
from trace import fullmodname
+APP_ENGINE_PATH='../google_appengine'
+
# Conditional import of cleanup function
try:
from tests.utils import cleanup
@@ -15,6 +17,7 @@
# Ensure current working directory is in path
sys.path.insert(0, os.getcwd())
+sys.path.insert(0, APP_ENGINE_PATH)
def build_suite(folder, verbosity):
# find all of the test modules
@@ -60,6 +63,8 @@
elif verbosity == 2:
logging.basicConfig(level=logging.DEBUG)
+ import dev_appserver
+ dev_appserver.fix_sys_path()
# Allow user to run a specific folder of tests
if 'tests' in sys.argv:
run('tests', verbosity, exit_on_failure)
diff --git a/samples/new_project_template/app.yaml b/samples/new_project_template/app.yaml
index 33f1a19..c0d43d5 100644
--- a/samples/new_project_template/app.yaml
+++ b/samples/new_project_template/app.yaml
@@ -4,6 +4,9 @@
api_version: 1
handlers:
+- url: /oauth2callback
+ script: oauth2client/appengine.py
+
- url: .*
script: main.py
diff --git a/samples/new_project_template/grant.html b/samples/new_project_template/grant.html
new file mode 100644
index 0000000..b199ea0
--- /dev/null
+++ b/samples/new_project_template/grant.html
@@ -0,0 +1,17 @@
+<html>
+ <head>
+ <title>Can Haz Perms?</title>
+ </head>
+ <body>
+ {% if has_credentials %}
+ <p>Thanks for granting us permission. Please <a href="/">proceed to the main
+ application</a>.</p>
+ {% else %}
+ <p><a href="{{ url }}">Grant</a> this application permission to read your
+ Buzz information and it will let you know how many followers you have.</p>
+ {% endif %}
+ <p>You can always <a
+ href="https://www.google.com/accounts/b/0/IssuedAuthSubTokens">revoke</a>
+ permission at any time.</p>
+ </body>
+</html>
diff --git a/samples/new_project_template/main.py b/samples/new_project_template/main.py
index c451e67..520fbb4 100755
--- a/samples/new_project_template/main.py
+++ b/samples/new_project_template/main.py
@@ -31,100 +31,64 @@
import pickle
from apiclient.discovery import build
-from oauth2client.appengine import CredentialsProperty
-from oauth2client.appengine import StorageByKeyName
-from oauth2client.client import OAuth2WebServerFlow
+from oauth2client.appengine import OAuth2Decorator
+from oauth2client.client import AccessTokenRefreshError
from google.appengine.api import memcache
from google.appengine.api import users
from google.appengine.ext import db
from google.appengine.ext import webapp
from google.appengine.ext.webapp import template
-from google.appengine.ext.webapp import util
-from google.appengine.ext.webapp.util import login_required
+from google.appengine.ext.webapp.util import run_wsgi_app
-# Set up a Flow object to be used if we need to authenticate. This
-# sample uses OAuth 2.0, and we set up the OAuth2WebServerFlow with
-# the information it needs to authenticate. Note that it is called
-# the Web Server Flow, but it can also handle the flow for native
-# applications <http://code.google.com/apis/accounts/docs/OAuth2.html#IA>
-# The client_id and client_secret are copied from the Identity tab on
+# The client_id and client_secret are copied from the API Access tab on
# the Google APIs Console <http://code.google.com/apis/console>
-
-FLOW = OAuth2WebServerFlow(
- client_id='<client id goes here>',
- client_secret='<client secret goes here>',
+decorator = OAuth2Decorator(
+ client_id='837647042410-75ifgipj95q4agpm0cs452mg7i2pn17c.apps.googleusercontent.com',
+ client_secret='QhxYsjM__u4vy5N0DXUFRwwI',
scope='https://www.googleapis.com/auth/buzz',
user_agent='my-sample-app/1.0')
-class Credentials(db.Model):
- credentials = CredentialsProperty()
-
-
class MainHandler(webapp.RequestHandler):
- @login_required
+ @decorator.oauth_required
def get(self):
- user = users.get_current_user()
- credentials = StorageByKeyName(
- Credentials, user.user_id(), 'credentials').get()
-
- if not credentials or credentials.invalid:
- return begin_oauth_flow(self, user)
-
- http = credentials.authorize(httplib2.Http())
+ http = decorator.http()
# Build a service object for interacting with the API. Visit
# the Google APIs Console <http://code.google.com/apis/console>
# to get a developerKey for your own application.
- service = build("buzz", "v1", http=http)
- followers = service.people().list(
- userId='@me', groupId='@followers').execute()
- text = 'Hello, you have %s followers!' % followers['totalResults']
+ try:
+ service = build("buzz", "v1", http=http)
+ followers = service.people().list(
+ userId='@me', groupId='@followers').execute()
+ text = 'Hello, you have %s followers!' % followers['totalResults']
- path = os.path.join(os.path.dirname(__file__), 'welcome.html')
- self.response.out.write(template.render(path, {'text': text }))
+ path = os.path.join(os.path.dirname(__file__), 'welcome.html')
+ self.response.out.write(template.render(path, {'text': text }))
+ except AccessTokenRefreshError:
+ self.redirect('/grant')
-def begin_oauth_flow(request_handler, user):
- callback = request_handler.request.relative_url('/oauth2callback')
- authorize_url = FLOW.step1_get_authorize_url(callback)
- # Here we are using memcache to store the flow temporarily while the user
- # is directed to authorize our service. You could also store the flow
- # in the datastore depending on your utilization of memcache, just remember
- # in that case to clean up the flow after you are done with it.
- memcache.set(user.user_id(), pickle.dumps(FLOW))
- request_handler.redirect(authorize_url)
+class GrantHandler(webapp.RequestHandler):
-
-class OAuthHandler(webapp.RequestHandler):
-
- @login_required
+ @decorator.oauth_aware
def get(self):
- user = users.get_current_user()
- flow = pickle.loads(memcache.get(user.user_id()))
- # This code should be ammended with application specific error
- # handling. The following cases should be considered:
- # 1. What if the flow doesn't exist in memcache? Or is corrupt?
- # 2. What if the step2_exchange fails?
- if flow:
- credentials = flow.step2_exchange(self.request.params)
- StorageByKeyName(
- Credentials, user.user_id(), 'credentials').put(credentials)
- self.redirect("/")
- else:
- # Add application specific error handling here.
- pass
-
+ path = os.path.join(os.path.dirname(__file__), 'grant.html')
+ variables = {
+ 'url': decorator.authorize_url(),
+ 'has_credentials': decorator.has_credentials()
+ }
+ self.response.out.write(template.render(path, variables))
def main():
application = webapp.WSGIApplication(
[
- ('/', MainHandler),
- ('/oauth2callback', OAuthHandler)
+ ('/', MainHandler),
+ ('/grant', GrantHandler),
],
debug=True)
- util.run_wsgi_app(application)
+ run_wsgi_app(application)
if __name__ == '__main__':
diff --git a/samples/new_project_template/welcome.html b/samples/new_project_template/welcome.html
index 0117f50..57b186e 100644
--- a/samples/new_project_template/welcome.html
+++ b/samples/new_project_template/welcome.html
@@ -1,6 +1,6 @@
<html>
<head>
- <title>Bootcamp Translations</title>
+ <title>Welcome</title>
</head>
<body>
<p>{{ text }}</p>
diff --git a/tests/test_oauth2client_appengine.py b/tests/test_oauth2client_appengine.py
new file mode 100644
index 0000000..208a3f5
--- /dev/null
+++ b/tests/test_oauth2client_appengine.py
@@ -0,0 +1,176 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2010 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.
+
+
+"""Discovery document tests
+
+Unit tests for objects created from discovery documents.
+"""
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+import httplib2
+import unittest
+import urlparse
+
+try:
+ from urlparse import parse_qs
+except ImportError:
+ from cgi import parse_qs
+
+from apiclient.http import HttpMockSequence
+from apiclient.anyjson import simplejson
+from webtest import TestApp
+from oauth2client.client import AccessTokenRefreshError
+from oauth2client.client import FlowExchangeError
+from oauth2client.appengine import OAuth2Decorator
+from google.appengine.ext import webapp
+from google.appengine.api import users
+from oauth2client.appengine import OAuth2Handler
+from google.appengine.ext import testbed
+
+
+class UserMock(object):
+ """Mock the app engine user service"""
+ def user_id(self):
+ return 'foo_user'
+
+
+class Http2Mock(object):
+ """Mock httplib2.Http"""
+ status = 200
+ content = {
+ 'access_token': 'foo_access_token',
+ 'refresh_token': 'foo_refresh_token',
+ 'expires_in': 3600
+ }
+
+ def request(self, token_uri, method, body, headers, *args, **kwargs):
+ self.body = body
+ self.headers = headers
+ return (self, simplejson.dumps(self.content))
+
+
+class DecoratorTests(unittest.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_user_stub()
+
+ decorator = OAuth2Decorator(client_id='foo_client_id',
+ client_secret='foo_client_secret',
+ scope='foo_scope',
+ user_agent='foo_user_agent')
+ self.decorator = decorator
+
+
+ class TestRequiredHandler(webapp.RequestHandler):
+ @decorator.oauth_required
+ def get(self):
+ pass
+
+
+ class TestAwareHandler(webapp.RequestHandler):
+ @decorator.oauth_aware
+ def get(self):
+ self.response.out.write('Hello World!')
+
+
+ application = webapp.WSGIApplication([('/oauth2callback', OAuth2Handler),
+ ('/foo_path', TestRequiredHandler),
+ ('/bar_path', TestAwareHandler)],
+ debug=True)
+ self.app = TestApp(application)
+ users.get_current_user = UserMock
+ httplib2.Http = Http2Mock
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_required(self):
+ # An initial request to an oauth_required decorated path should be a
+ # redirect to start the OAuth dance.
+ response = self.app.get('/foo_path')
+ self.assertTrue(response.status.startswith('302'))
+ q = parse_qs(response.headers['Location'].split('?', 1)[1])
+ self.assertEqual('http://localhost/oauth2callback', q['redirect_uri'][0])
+ self.assertEqual('foo_client_id', q['client_id'][0])
+ self.assertEqual('foo_scope', q['scope'][0])
+ self.assertEqual('http://localhost/foo_path', q['state'][0])
+ self.assertEqual('code', q['response_type'][0])
+ self.assertEqual(False, self.decorator.has_credentials())
+
+ # Now simulate the callback to /oauth2callback
+ response = self.app.get('/oauth2callback', {
+ 'code': 'foo_access_code',
+ 'state': 'foo_path'
+ })
+ self.assertEqual('http://localhost/foo_path', response.headers['Location'])
+ self.assertEqual(None, self.decorator.credentials)
+
+ # Now requesting the decorated path should work
+ response = self.app.get('/foo_path')
+ self.assertEqual('200 OK', response.status)
+ self.assertEqual(True, self.decorator.has_credentials())
+ self.assertEqual('foo_refresh_token', self.decorator.credentials.refresh_token)
+ self.assertEqual('foo_access_token', self.decorator.credentials.access_token)
+
+ # Invalidate the stored Credentials
+ self.decorator.credentials._invalid = True
+ self.decorator.credentials.store(self.decorator.credentials)
+
+ # Invalid Credentials should start the OAuth dance again
+ response = self.app.get('/foo_path')
+ self.assertTrue(response.status.startswith('302'))
+ q = parse_qs(response.headers['Location'].split('?', 1)[1])
+ self.assertEqual('http://localhost/oauth2callback', q['redirect_uri'][0])
+
+ def test_aware(self):
+ # An initial request to an oauth_aware decorated path should not redirect
+ response = self.app.get('/bar_path')
+ self.assertEqual('Hello World!', response.body)
+ self.assertEqual('200 OK', response.status)
+ self.assertEqual(False, self.decorator.has_credentials())
+ url = self.decorator.authorize_url()
+ q = parse_qs(url.split('?', 1)[1])
+ self.assertEqual('http://localhost/oauth2callback', q['redirect_uri'][0])
+ self.assertEqual('foo_client_id', q['client_id'][0])
+ self.assertEqual('foo_scope', q['scope'][0])
+ self.assertEqual('http://localhost/bar_path', q['state'][0])
+ self.assertEqual('code', q['response_type'][0])
+
+ # Now simulate the callback to /oauth2callback
+ url = self.decorator.authorize_url()
+ response = self.app.get('/oauth2callback', {
+ 'code': 'foo_access_code',
+ 'state': 'bar_path'
+ })
+ self.assertEqual('http://localhost/bar_path', response.headers['Location'])
+ self.assertEqual(False, self.decorator.has_credentials())
+
+ # Now requesting the decorated path will have credentials
+ response = self.app.get('/bar_path')
+ self.assertEqual('200 OK', response.status)
+ self.assertEqual('Hello World!', response.body)
+ self.assertEqual(True, self.decorator.has_credentials())
+ self.assertEqual('foo_refresh_token', self.decorator.credentials.refresh_token)
+ self.assertEqual('foo_access_token', self.decorator.credentials.access_token)
+
+if __name__ == '__main__':
+ unittest.main()