Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

initial commit with mostly working implementation

  • Loading branch information...
commit 4a8f34d5330ad4d7c6172fa01832cc14994e5157 0 parents
@progrium progrium authored
26 README.md
@@ -0,0 +1,26 @@
+! OAuth2 on App Engine
+
+This is an implementation of an OAuth2 (draft 10ish) authorization server with example resource server. It was written to be as concise and readable as possible, so you can better see how the spec is implemented to either implement your own or use this as a starting point for your own.
+
+!! Running it
+
+You can run this server locally by downloading the App Engine SDK and running it with their desktop application or command line tool. Alternatively, you can deploy this on App Engine for free with a Google account.
+
+!! Using it
+
+Okay, you've got a working implementation of an OAuth2 server. Now what? Presumably, you're here because you're interested in providing OAuth2 for your web service. Here are your options:
+
+ * If your app is Python, use this code to get you started with your own implementation
+ * If your app is something else, use this code as a reference for your own implementation
+ * If you're altruistic, use this code as reference to build an OAuth2 library for your language
+ * Alternatively, you can tweak this code to BE your authorization server running on App Engine
+
+!! Contributing to it
+
+We're hoping this project not only helps you learn OAuth2 for your own implementations, but eventually we'd like to make it a library for Python. This requires a number of complications and abstractions that will make reading it for reference a bit more difficult. So the plan is to approach this slowly and intelligently with the least number of abstractions that do the job. Hopefully, this library will then also be a reference for libraries in other languages. This implementation itself is loosely based on an older Ruby OAuth2 server implementation/library (http://github.com/ThoughtWorksStudios/oauth2_provider).
+
+HOWEVER, for the time being, the primary goal of this project is to provide a full implementation of the latest OAuth2 spec, which is currently at draft 10, with about 3 more planned before it becomes finalized in early 2011.
+
+!! Authors
+
+ * Jeff Lindsay <progrium@gmail.com>
8 app.yaml
@@ -0,0 +1,8 @@
+application: gae-oauth
+version: 1
+runtime: python
+api_version: 1
+
+handlers:
+- url: .*
+ script: main.py
11 index.yaml
@@ -0,0 +1,11 @@
+indexes:
+
+# AUTOGENERATED
+
+# This index.yaml is automatically updated whenever the dev_appserver
+# detects that a new type of query is run. If you want to manage the
+# index.yaml file manually, remove the above marker line (the line
+# saying "# AUTOGENERATED"). If you want to manage some indexes
+# manually, move them above the marker line. The index.yaml file is
+# automatically uploaded to the admin console when you next deploy
+# your application using appcfg.py.
47 main.py
@@ -0,0 +1,47 @@
+from google.appengine.ext import webapp, db
+from django.utils import simplejson
+
+from google.appengine.ext.webapp import util, template
+from google.appengine.api import users
+import urllib
+
+from oauth.handlers import AuthorizationHandler, AccessTokenHandler
+from oauth.models import OAuth_Client
+
+# Notes:
+# Access tokens usually live shorter than access grant
+# Refresh tokens usually live as long as access grant
+
+
+class MainHandler(webapp.RequestHandler):
+ def get(self):
+ self.response.out.write('Hello world!')
+
+class ClientsHandler(webapp.RequestHandler):
+ """ This is only indirectly necessary since the spec
+ calls for clients, but managing them is out of scope
+ """
+
+ def get(self):
+ clients = OAuth_Client.all()
+ self.response.out.write(
+ template.render('templates/clients.html', locals()))
+
+ def post(self):
+ client = OAuth_Client(
+ name = self.request.get('name'),
+ redirect_uri = self.request.get('redirect_uri'), )
+ client.put()
+ self.redirect(self.request.path)
+
+
+def main():
+ application = webapp.WSGIApplication([
+ ('/', MainHandler),
+ ('/oauth/authorize', AuthorizationHandler),
+ ('/oauth/token', AccessTokenHandler),
+ ('/app/clients', ClientsHandler), ],debug=True)
+ util.run_wsgi_app(application)
+
+if __name__ == '__main__':
+ main()
0  oauth/__init__.py
No changes.
215 oauth/handlers.py
@@ -0,0 +1,215 @@
+from google.appengine.ext import webapp, db
+from google.appengine.ext.webapp import template, util
+from google.appengine.api import users
+from django.utils import simplejson
+
+import urllib
+
+from oauth.models import OAuth_Authorization, OAuth_Token, OAuth_Client
+from oauth.utils import extract
+
+class AuthorizationHandler(webapp.RequestHandler):
+ SUPPORTED_RESPONSE_TYPES = [
+ 'code',
+ 'token',
+ 'code_and_token', ] # NOTE: code_and_token may be removed in spec
+
+ def authz_redirect(self, query, fragment=None):
+ query_string = ('?%s' % urllib.urlencode(query)) if query else ''
+ fragment_string = ('#%s' % urllib.urlencode(fragment)) if fragment else ''
+ self.redirect(''.join([self.redirect_uri, query_string, fragment_string]))
+
+ def authz_error(self, code, description=None):
+ error = {'error': code, 'error_description': description}
+ if self.request.get('state'):
+ error['state'] = self.request.get('state')
+ self.authz_redirect(error)
+
+ def validate_params(self):
+ self.user = users.get_current_user()
+ if self.request.method == 'POST' and not self.user:
+ self.error(403)
+ self.response.out.write("Authentication required.")
+ return False
+
+ self.redirect_uri = self.request.get('redirect_uri')
+ if not self.redirect_uri:
+ self.error(400)
+ self.response.out.write("The parameter redirect_uri is required.")
+ return False
+ # TODO: validate url?
+
+ if not self.request.get('response_type') in self.SUPPORTED_RESPONSE_TYPES:
+ self.authz_error('unsupported_response_type', "The requested response type is not supported.")
+ return False
+
+ self.client = OAuth_Client.get_by_client_id(self.request.get('client_id'))
+ if not self.client:
+ self.authz_error('invalid_client', "The client identifier provided is invalid.")
+ return False
+
+ if self.client.redirect_uri:
+ if self.client.redirect_uri != self.redirect_uri:
+ self.authz_error('redirect_uri_mismatch',
+ "The redirection URI provided does not match a pre-registered value.")
+ return False
+
+ return True
+
+ @util.login_required
+ def get(self):
+ # TODO: put scope into ui
+ if not self.validate_params():
+ return
+ template_data = extract([
+ 'response_type',
+ 'redirect_uri',
+ 'client_id',
+ 'scope',
+ 'state',], self.request.GET)
+ template_data['client'] = self.client
+ self.response.out.write(
+ template.render('templates/authorize.html', template_data))
+
+ def post(self):
+ if not self.validate_params():
+ return
+
+ # TODO: check for some sort of cross site request forgery? sign the request?
+
+ if self.request.get('authorize').lower() == 'no':
+ self.authz_error('access_denied', "The user did not allow authorization.")
+ return
+
+ response_type = self.request.get('response_type')
+
+ if response_type in ['code', 'code_and_token']:
+ code = OAuth_Authorization(
+ user_id = self.user.user_id(),
+ client_id = self.client.client_id,
+ redirect_uri = self.redirect_uri, )
+ code.put()
+ code = code.serialize(state=self.request.get('state'))
+ else:
+ code = None
+
+ if response_type in ['token', 'code_and_token']:
+ token = OAuth_Token(
+ user_id = self.user.user_id(),
+ client_id = self.client.client_id,
+ scope = self.request.get('scope'), )
+ token.put(can_refresh=False)
+ token = token.serialize(requested_scope=self.request.get('scope'))
+ else:
+ token = None
+
+ self.authz_redirect(code, token)
+
+
+
+class AccessTokenHandler(webapp.RequestHandler):
+ SUPPORTED_GRANT_TYPES = [
+ 'client_credentials',
+ 'refresh_token',
+ 'authorization_code',
+ 'password',
+ #'none', (will require not giving refresh token) ... == client_credentials?
+ #'asssertion', (will require not giving refresh token)
+ ]
+
+ def render_error(self, code, description):
+ self.error(400)
+ self.response.headers['content-type'] = 'application/json'
+ self.response.out.write(simplejson.dumps(
+ {'error': code, 'error_description': description,}))
+
+ def render_response(self, token):
+ self.response.headers['content-type'] = 'application/json'
+ self.response.out.write(simplejson.dumps(
+ token.serialize(requested_scope=self.request.get('scope'))))
+
+ def get(self):
+ """ This method MAY be supported according to spec """
+ self.handle()
+
+ def post(self):
+ """ This method MUST be supported according to spec """
+ self.handle()
+
+ def handle(self):
+ # TODO: MUST require transport-level security
+ client_id = self.request.get('client_id')
+ client_secret = self.request.get('client_secret')
+ grant_type = self.request.get('grant_type')
+ scope = self.request.get('scope')
+
+ if not grant_type in self.SUPPORTED_GRANT_TYPES:
+ self.render_error('unsupported_grant_type', "Grant type not supported.")
+ return
+
+ client = OAuth_Client.authenticate(client_id, client_secret)
+ if not client:
+ self.render_error('invalid_client', "Inavlid client credentials.")
+ return
+
+ # Dispatch to one of the grant handlers below
+ getattr(self, 'handle_%s' % grant_type)(client, scope)
+
+ def handle_password(self, client, scope=None):
+ # Since App Engine doesn't let you programmatically auth,
+ # and the local SDK environment doesn't need a password,
+ # we just always grant this w/out auth
+ # TODO: something better?
+
+ username = self.request.get('username')
+ password = self.request.get('password')
+
+ if not username or not password:
+ self.render_error('invalid_grant', "Invalid end-user credentials.")
+ return
+
+ token = OAuth_Token(
+ client_id = client.client_id,
+ user_id = username,
+ scope = scope, )
+ token.put()
+
+ self.render_response(token)
+
+ def handle_client_credentials(self, client, scope=None):
+ token = OAuth_Token(
+ client_id = client.client_id,
+ scope = scope, )
+ token.put(can_refresh=False)
+
+ self.render_response(token)
+
+ def handle_refresh_token(self, client, scope=None):
+ token = OAuth_Token.get_by_refresh_token(self.request.get('refresh_token'))
+
+ if not token or token.client_id != client.client_id:
+ self.render_error('invalid_grant', "Invalid refresh token.")
+ return
+
+ # TODO: refresh token should expire along with grant according to spec
+ token = token.refresh()
+
+ self.render_response(token)
+
+ def handle_authorization_code(self, client, scope=None):
+ authorization = OAuth_Authorization.get_by_code(self.request.get('code'))
+ redirect_uri = self.request.get('redirect_url')
+
+ if not authorization or not authorization.validate(code, redirect_uri, client.client_id):
+ self.render_error('invalid_grant', "Authorization code expired or invalid.")
+ return
+
+ token = OAuth_Token(
+ user_id = authorization.user_id,
+ client_id = authorization.client_id,
+ scope = scope, )
+ token.put()
+ authorization.delete()
+
+ self.render_response(token)
+
113 oauth/models.py
@@ -0,0 +1,113 @@
+from google.appengine.ext import db
+from oauth.utils import random_str, now
+
+class OAuth_Token(db.Model):
+ EXPIRY_TIME = 3600*24
+
+ user_id = db.StringProperty()
+ client_id = db.StringProperty()
+ access_token = db.StringProperty()
+ refresh_token = db.StringProperty(required=False)
+ scope = db.StringProperty(required=False)
+ expires = db.IntegerProperty(required=False)
+
+ @classmethod
+ def get_by_refresh_token(cls, refresh_token):
+ return cls.all().filter('refresh_token =', refresh_token).get()
+
+ def put(self, can_refresh=True):
+ if can_refresh:
+ self.refresh_token = random_str()
+ self.access_token = random_str()
+ self.expires = now() + self.EXPIRY_TIME
+ super(OAuth_Token, self).put()
+
+ def refresh(self):
+ if not self.refresh_token:
+ return None # Raise exception?
+
+ token = OAuth_Token(
+ client_id = self.client_id,
+ user_id = self.user_id,
+ scope = self.scope, )
+ token.put()
+ self.delete()
+ return token
+
+ def is_expired(self):
+ return self.expires < now()
+
+ def serialize(self, requested_scope=None):
+ token = dict(
+ access_token = self.access_token,
+ expires_in = self.expires - now(), )
+ if (self.scope and not requested_scope) \
+ or (requested_scope and self.scope != requested_scope):
+ token['scope'] = self.scope
+ if self.refresh_token:
+ token['refresh_token'] = self.refresh_token
+ return token
+
+
+class OAuth_Authorization(db.Model):
+ EXPIRY_TIME = 3600
+
+ user_id = db.StringProperty()
+ client_id = db.StringProperty()
+ code = db.StringProperty()
+ redirect_uri = db.StringProperty()
+ expires = db.IntegerProperty()
+
+ @classmethod
+ def get_by_code(cls, code):
+ return cls.all().filter('code =', code).get()
+
+ def put(self):
+ self.code = random_str()
+ self.expires = now() + self.EXPIRY_TIME
+ super(OAuth_Authorization, self).put()
+
+ def is_expired(self):
+ return self.expires < now()
+
+ def validate(self, code, redirect_uri, client_id=None):
+ valid = not self.is_expired() \
+ and self.code == code \
+ and self.redirect_uri == redirect_uri
+ if client_id:
+ valid &= self.client_id == client_id
+ return valid
+
+ def serialize(self, state=None):
+ authz = {'code': self.code}
+ if state:
+ authz['state'] = state
+ return authz
+
+
+
+class OAuth_Client(db.Model):
+ client_id = db.StringProperty()
+ client_secret = db.StringProperty()
+ redirect_uri = db.StringProperty()
+
+ # This is not necessary according to spec,
+ # however, effectively you need it for UX
+ name = db.StringProperty()
+
+ @classmethod
+ def get_by_client_id(cls, client_id):
+ return cls.all().filter('client_id =', client_id).get()
+
+ @classmethod
+ def authenticate(cls, client_id, client_secret):
+ client = cls.get_by_client_id(client_id)
+ if client and client.client_secret == client_secret:
+ return client
+ else:
+ return None
+
+ def put(self):
+ self.client_id = random_str()
+ self.client_secret = random_str()
+ super(OAuth_Client, self).put()
13 oauth/utils.py
@@ -0,0 +1,13 @@
+import time
+import hashlib
+import random
+
+def now():
+ return int(time.mktime(time.gmtime()))
+
+def random_str():
+ return hashlib.sha1(str(random.random())).hexdigest()
+
+def extract(keys, d):
+ """ Extracts subset of a dict into new dict """
+ return dict((k, d[k]) for k in keys if k in d)
71 spec/oauth_server.rb
@@ -0,0 +1,71 @@
+require 'spec'
+require 'mechanize'
+require 'json'
+
+def server_url(path)
+ "http://localhost:#{ENV['PORT']}#{path}"
+end
+
+CLIENT_ID = 'b3a8d6fbe43a3a95ac5a7bd95e0a89fcd4eb6302'
+CLIENT_SECRET = 'b6448e179471d13910829b9711ad06cd83f8f650'
+CLIENT_REDIRECT = 'http://localhost:8080'
+OWNER_USERNAME = 'ac123'
+OWNER_PASSWORD = '123'
+
+describe 'OAuth Access Token Endpoint' do
+ it "lets client obtain access token with end-user credentials" do
+ page = Mechanize.new.post(server_url("/oauth/token"), {
+ "grant_type" => "password",
+ "client_id" => CLIENT_ID,
+ "client_secret" => CLIENT_SECRET,
+ "username" => OWNER_USERNAME,
+ "password" => OWNER_PASSWORD,
+ })
+ response = JSON.parse(page.body)
+ response.keys.should include("access_token")
+ end
+
+ it "lets client obtain access token with a refresh token" do
+ page = Mechanize.new.post(server_url("/oauth/token"), {
+ "grant_type" => "password",
+ "client_id" => CLIENT_ID,
+ "client_secret" => CLIENT_SECRET,
+ "username" => OWNER_USERNAME,
+ "password" => OWNER_PASSWORD,
+ })
+ response = JSON.parse(page.body)
+ refresh_token = response['refresh_token']
+ page = Mechanize.new.post(server_url("/oauth/token"), {
+ "grant_type" => "refresh_token",
+ "client_id" => CLIENT_ID,
+ "client_secret" => CLIENT_SECRET,
+ "refresh_token" => refresh_token,
+ })
+ response = JSON.parse(page.body)
+ response.keys.should include("access_token")
+ end
+
+ it "lets client obtain access token with only client credentials" do
+ page = Mechanize.new.post(server_url("/oauth/token"), {
+ "grant_type" => "client_credentials",
+ "client_id" => CLIENT_ID,
+ "client_secret" => CLIENT_SECRET,
+ })
+ response = JSON.parse(page.body)
+ response.keys.should include("access_token")
+ end
+end
+
+describe 'OAuth Authorization Endpoint' do
+ it "lets a client get end-user authorization" do
+ page = Mechanize.new.post(server_url("/oauth/authorize"), {
+ "response_type" => "code",
+ "client_id" => CLIENT_ID,
+ "redirect_uri" => CLIENT_REDIRECT,
+ })
+ # do local app engine sdk auth
+
+
+ page.body.should include(CLIENT_ID)
+ end
+end
11 templates/authorize.html
@@ -0,0 +1,11 @@
+<form method="post">
+Do you wish to allow the service named '{{client.name}}' to access this application on your behalf?
+<input type="submit" value="Yes" name="authorize" />
+<input type="submit" value="No" name="authorize" />
+
+<input type="hidden" name="client_id" id="client_id" value="{{client_id}}" />
+<input type="hidden" name="redirect_uri" id="redirect_uri" value="{{redirect_uri}}" />
+<input type="hidden" name="response_type" id="response_type" value="{{response_type}}" />
+<input type="hidden" name="state" id="state" value="{{state}}" />
+<input type="hidden" name="scope" id="scope" value="{{scope}}" />
+</form>
12 templates/clients.html
@@ -0,0 +1,12 @@
+<h4>Clients</h4>
+<ul>
+ {% for client in clients %}
+ <li>{{client.name}} : {{client.client_id}} : {{client.client_secret}} : {{client.redirect_uri}}
+ {% endfor %}
+</ul>
+<strong>Create Client</strong>
+<form method="post">
+ Name: <input type="text" name="name" /><br />
+ Redirect URI: <input type="text" name="redirect_uri" /><br />
+ <input type="submit" value="Save" />
+</form>
Please sign in to comment.
Something went wrong with that request. Please try again.