├── README.md ├── app.yaml ├── index.yaml ├── main.py ├── oauth ├── __init__.py ├── handlers.py ├── models.py └── utils.py ├── spec └── oauth_server.rb ├── templates ├── authorize.html └── clients.html └── tests └── main_test.py /README.md: -------------------------------------------------------------------------------- 1 | # OAuth2 on App Engine 2 | 3 | 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. 4 | 5 | ## Running it 6 | 7 | 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. 8 | 9 | ## Using it 10 | 11 | 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: 12 | 13 | * If your app is Python, use this code to get you started with your own implementation 14 | * If your app is something else, use this code as a reference for your own implementation 15 | * If you're altruistic, use this code as reference to build an OAuth2 library for your language 16 | * Alternatively, you can tweak this code to BE your authorization server running on App Engine 17 | 18 | ## Contributing to it 19 | 20 | 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). 21 | 22 | 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. 23 | 24 | That said, we welcome contributors to keep it up to date and add examples of all the base OAuth2 functionality. Feel free to submit issues or fork and send pull requests. Just be sure to add integration tests (which are currently still a work in progress). 25 | 26 | ## Contributors 27 | 28 | * Jeff Lindsay 29 | 30 | ## License 31 | 32 | MIT -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | application: gae-oauth 2 | version: 1 3 | runtime: python 4 | api_version: 1 5 | 6 | handlers: 7 | - url: .* 8 | script: main.py 9 | -------------------------------------------------------------------------------- /index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | 3 | # AUTOGENERATED 4 | 5 | # This index.yaml is automatically updated whenever the dev_appserver 6 | # detects that a new type of query is run. If you want to manage the 7 | # index.yaml file manually, remove the above marker line (the line 8 | # saying "# AUTOGENERATED"). If you want to manage some indexes 9 | # manually, move them above the marker line. The index.yaml file is 10 | # automatically uploaded to the admin console when you next deploy 11 | # your application using appcfg.py. 12 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import webapp, db 2 | from django.utils import simplejson 3 | 4 | from google.appengine.ext.webapp import util, template 5 | from google.appengine.api import users 6 | import urllib 7 | 8 | from oauth.handlers import AuthorizationHandler, AccessTokenHandler 9 | from oauth.models import OAuth_Client 10 | from oauth.utils import oauth_required 11 | 12 | # Notes: 13 | # Access tokens usually live shorter than access grant 14 | # Refresh tokens usually live as long as access grant 15 | 16 | 17 | class MainHandler(webapp.RequestHandler): 18 | def get(self): 19 | self.response.out.write('Hello world!') 20 | 21 | class ClientsHandler(webapp.RequestHandler): 22 | """ This is only indirectly necessary since the spec 23 | calls for clients, but managing them is out of scope """ 24 | 25 | def get(self): 26 | clients = OAuth_Client.all() 27 | self.response.out.write( 28 | template.render('templates/clients.html', locals())) 29 | 30 | def post(self): 31 | client = OAuth_Client( 32 | name = self.request.get('name'), 33 | redirect_uri = self.request.get('redirect_uri'), ) 34 | client.put() 35 | self.redirect(self.request.path) 36 | 37 | class ProtectedResourceHandler(webapp.RequestHandler): 38 | """ This is an example of a resource protected by OAuth 39 | and requires the 'read' scope """ 40 | 41 | SECRET_PAYLOAD = 'bananabread' 42 | 43 | @oauth_required(scope='read') 44 | def get(self, token): 45 | self.response.headers['Content-Type'] = "application/json" 46 | self.response.out.write( 47 | simplejson.dumps({'is_protected': True, 'secret_payload': self.SECRET_PAYLOAD})) 48 | 49 | def application(): 50 | return webapp.WSGIApplication([ 51 | ('/', MainHandler), 52 | ('/oauth/authorize', AuthorizationHandler), 53 | ('/oauth/token', AccessTokenHandler), 54 | ('/protected/resource', ProtectedResourceHandler), 55 | ('/admin/clients', ClientsHandler), ],debug=True) 56 | 57 | def main(): 58 | util.run_wsgi_app(application()) 59 | 60 | if __name__ == '__main__': 61 | main() 62 | -------------------------------------------------------------------------------- /oauth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progrium/oauth2-appengine/fbbee9e609c212fc933e8966a7733322474adead/oauth/__init__.py -------------------------------------------------------------------------------- /oauth/handlers.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import webapp, db 2 | from google.appengine.ext.webapp import template, util 3 | from google.appengine.api import users 4 | from django.utils import simplejson 5 | 6 | import urllib 7 | 8 | from oauth.models import OAuth_Authorization, OAuth_Token, OAuth_Client 9 | 10 | def extract(keys, d): 11 | """ Extracts subset of a dict into new dict """ 12 | return dict((k, d[k]) for k in keys if k in d) 13 | 14 | class AuthorizationHandler(webapp.RequestHandler): 15 | SUPPORTED_RESPONSE_TYPES = [ 16 | 'code', 17 | 'token', 18 | 'code_and_token', ] # NOTE: code_and_token may be removed in spec 19 | 20 | def authz_redirect(self, query, fragment=None): 21 | query_string = ('?%s' % urllib.urlencode(query)) if query else '' 22 | fragment_string = ('#%s' % urllib.urlencode(fragment)) if fragment else '' 23 | self.redirect(''.join([self.redirect_uri, query_string, fragment_string])) 24 | 25 | def authz_error(self, code, description=None): 26 | error = {'error': code, 'error_description': description} 27 | if self.request.get('state'): 28 | error['state'] = self.request.get('state') 29 | self.authz_redirect(error) 30 | 31 | def validate_params(self): 32 | self.user = users.get_current_user() 33 | if self.request.method == 'POST' and not self.user: 34 | self.error(403) 35 | self.response.out.write("Authentication required.") 36 | return False 37 | 38 | self.redirect_uri = self.request.get('redirect_uri') 39 | if not self.redirect_uri: 40 | self.error(400) 41 | self.response.out.write("The parameter redirect_uri is required.") 42 | return False 43 | # TODO: validate url? 44 | 45 | if not self.request.get('response_type') in self.SUPPORTED_RESPONSE_TYPES: 46 | self.authz_error('unsupported_response_type', "The requested response type is not supported.") 47 | return False 48 | 49 | self.client = OAuth_Client.get_by_client_id(self.request.get('client_id')) 50 | if not self.client: 51 | self.authz_error('invalid_client', "The client identifier provided is invalid.") 52 | return False 53 | 54 | if self.client.redirect_uri: 55 | if self.client.redirect_uri != self.redirect_uri: 56 | self.authz_error('redirect_uri_mismatch', 57 | "The redirection URI provided does not match a pre-registered value.") 58 | return False 59 | 60 | return True 61 | 62 | @util.login_required 63 | def get(self): 64 | # TODO: put scope into ui 65 | if not self.validate_params(): 66 | return 67 | template_data = extract([ 68 | 'response_type', 69 | 'redirect_uri', 70 | 'client_id', 71 | 'scope', 72 | 'state',], self.request.GET) 73 | template_data['client'] = self.client 74 | self.response.out.write( 75 | template.render('templates/authorize.html', template_data)) 76 | 77 | def post(self): 78 | if not self.validate_params(): 79 | return 80 | 81 | # TODO: check for some sort of cross site request forgery? sign the request? 82 | 83 | if self.request.get('authorize').lower() == 'no': 84 | self.authz_error('access_denied', "The user did not allow authorization.") 85 | return 86 | 87 | response_type = self.request.get('response_type') 88 | 89 | if response_type in ['code', 'code_and_token']: 90 | code = OAuth_Authorization( 91 | user_id = self.user.user_id(), 92 | client_id = self.client.client_id, 93 | redirect_uri = self.redirect_uri, ) 94 | code.put() 95 | code = code.serialize(state=self.request.get('state')) 96 | else: 97 | code = None 98 | 99 | if response_type in ['token', 'code_and_token']: 100 | token = OAuth_Token( 101 | user_id = self.user.user_id(), 102 | client_id = self.client.client_id, 103 | scope = self.request.get('scope'), ) 104 | token.put(can_refresh=False) 105 | token = token.serialize(requested_scope=self.request.get('scope')) 106 | else: 107 | token = None 108 | 109 | self.authz_redirect(code, token) 110 | 111 | 112 | 113 | class AccessTokenHandler(webapp.RequestHandler): 114 | SUPPORTED_GRANT_TYPES = [ 115 | 'client_credentials', 116 | 'refresh_token', 117 | 'authorization_code', 118 | 'password', 119 | #'none', (will require not giving refresh token) ... == client_credentials? 120 | #'assertion', (will require not giving refresh token) 121 | ] 122 | 123 | def render_error(self, code, description): 124 | self.error(400) 125 | self.response.headers['content-type'] = 'application/json' 126 | self.response.out.write(simplejson.dumps( 127 | {'error': code, 'error_description': description,})) 128 | 129 | def render_response(self, token): 130 | self.response.headers['content-type'] = 'application/json' 131 | self.response.out.write(simplejson.dumps( 132 | token.serialize(requested_scope=self.request.get('scope')))) 133 | 134 | def get(self): 135 | """ This method MAY be supported according to spec """ 136 | self.handle() 137 | 138 | def post(self): 139 | """ This method MUST be supported according to spec """ 140 | self.handle() 141 | 142 | def handle(self): 143 | # TODO: MUST require transport-level security 144 | client_id = self.request.get('client_id') 145 | client_secret = self.request.get('client_secret') 146 | grant_type = self.request.get('grant_type') 147 | scope = self.request.get('scope') 148 | 149 | if not grant_type in self.SUPPORTED_GRANT_TYPES: 150 | self.render_error('unsupported_grant_type', "Grant type not supported.") 151 | return 152 | 153 | client = OAuth_Client.authenticate(client_id, client_secret) 154 | if not client: 155 | self.render_error('invalid_client', "Inavlid client credentials.") 156 | return 157 | 158 | # Dispatch to one of the grant handlers below 159 | getattr(self, 'handle_%s' % grant_type)(client, scope) 160 | 161 | def handle_password(self, client, scope=None): 162 | # Since App Engine doesn't let you programmatically auth, 163 | # and the local SDK environment doesn't need a password, 164 | # we just always grant this w/out auth 165 | # TODO: something better? 166 | 167 | username = self.request.get('username') 168 | password = self.request.get('password') 169 | 170 | if not username or not password: 171 | self.render_error('invalid_grant', "Invalid end-user credentials.") 172 | return 173 | 174 | token = OAuth_Token( 175 | client_id = client.client_id, 176 | user_id = username, 177 | scope = scope, ) 178 | token.put() 179 | 180 | self.render_response(token) 181 | 182 | def handle_client_credentials(self, client, scope=None): 183 | token = OAuth_Token( 184 | client_id = client.client_id, 185 | scope = scope, ) 186 | token.put(can_refresh=False) 187 | 188 | self.render_response(token) 189 | 190 | def handle_refresh_token(self, client, scope=None): 191 | token = OAuth_Token.get_by_refresh_token(self.request.get('refresh_token')) 192 | 193 | if not token or token.client_id != client.client_id: 194 | self.render_error('invalid_grant', "Invalid refresh token.") 195 | return 196 | 197 | # TODO: refresh token should expire along with grant according to spec 198 | token = token.refresh() 199 | 200 | self.render_response(token) 201 | 202 | def handle_authorization_code(self, client, scope=None): 203 | authorization = OAuth_Authorization.get_by_code(self.request.get('code')) 204 | redirect_uri = self.request.get('redirect_url') 205 | 206 | if not authorization or not authorization.validate(code, redirect_uri, client.client_id): 207 | self.render_error('invalid_grant', "Authorization code expired or invalid.") 208 | return 209 | 210 | token = OAuth_Token( 211 | user_id = authorization.user_id, 212 | client_id = authorization.client_id, 213 | scope = scope, ) 214 | token.put() 215 | authorization.delete() 216 | 217 | self.render_response(token) 218 | 219 | -------------------------------------------------------------------------------- /oauth/models.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import db 2 | import time 3 | import hashlib 4 | import random 5 | 6 | def now(): 7 | return int(time.mktime(time.gmtime())) 8 | 9 | def random_str(): 10 | return hashlib.sha1(str(random.random())).hexdigest() 11 | 12 | class OAuth_Token(db.Model): 13 | EXPIRY_TIME = 3600*24 14 | 15 | user_id = db.StringProperty() 16 | client_id = db.StringProperty() 17 | access_token = db.StringProperty() 18 | refresh_token = db.StringProperty(required=False) 19 | scope = db.StringProperty(required=False) 20 | expires = db.IntegerProperty(required=False) 21 | 22 | @classmethod 23 | def get_by_refresh_token(cls, refresh_token): 24 | return cls.all().filter('refresh_token =', refresh_token).get() 25 | 26 | @classmethod 27 | def get_by_access_token(cls, access_token): 28 | return cls.all().filter('access_token =', access_token).get() 29 | 30 | def put(self, can_refresh=True): 31 | if can_refresh: 32 | self.refresh_token = random_str() 33 | self.access_token = random_str() 34 | self.expires = now() + self.EXPIRY_TIME 35 | super(OAuth_Token, self).put() 36 | 37 | def refresh(self): 38 | if not self.refresh_token: 39 | return None # Raise exception? 40 | 41 | token = OAuth_Token( 42 | client_id = self.client_id, 43 | user_id = self.user_id, 44 | scope = self.scope, ) 45 | token.put() 46 | self.delete() 47 | return token 48 | 49 | def is_expired(self): 50 | return self.expires < now() 51 | 52 | def serialize(self, requested_scope=None): 53 | token = dict( 54 | access_token = self.access_token, 55 | expires_in = self.expires - now(), ) 56 | if (self.scope and not requested_scope) \ 57 | or (requested_scope and self.scope != requested_scope): 58 | token['scope'] = self.scope 59 | if self.refresh_token: 60 | token['refresh_token'] = self.refresh_token 61 | return token 62 | 63 | 64 | class OAuth_Authorization(db.Model): 65 | EXPIRY_TIME = 3600 66 | 67 | user_id = db.StringProperty() 68 | client_id = db.StringProperty() 69 | code = db.StringProperty() 70 | redirect_uri = db.StringProperty() 71 | expires = db.IntegerProperty() 72 | 73 | @classmethod 74 | def get_by_code(cls, code): 75 | return cls.all().filter('code =', code).get() 76 | 77 | def put(self): 78 | self.code = random_str() 79 | self.expires = now() + self.EXPIRY_TIME 80 | super(OAuth_Authorization, self).put() 81 | 82 | def is_expired(self): 83 | return self.expires < now() 84 | 85 | def validate(self, code, redirect_uri, client_id=None): 86 | valid = not self.is_expired() \ 87 | and self.code == code \ 88 | and self.redirect_uri == redirect_uri 89 | if client_id: 90 | valid &= self.client_id == client_id 91 | return valid 92 | 93 | def serialize(self, state=None): 94 | authz = {'code': self.code} 95 | if state: 96 | authz['state'] = state 97 | return authz 98 | 99 | 100 | 101 | class OAuth_Client(db.Model): 102 | client_id = db.StringProperty() 103 | client_secret = db.StringProperty() 104 | redirect_uri = db.StringProperty() 105 | 106 | # This is not necessary according to spec, 107 | # however, effectively you need it for UX 108 | name = db.StringProperty() 109 | 110 | @classmethod 111 | def get_by_client_id(cls, client_id): 112 | return cls.all().filter('client_id =', client_id).get() 113 | 114 | @classmethod 115 | def authenticate(cls, client_id, client_secret): 116 | client = cls.get_by_client_id(client_id) 117 | if client and client.client_secret == client_secret: 118 | return client 119 | else: 120 | return None 121 | 122 | def put(self): 123 | self.client_id = random_str() 124 | self.client_secret = random_str() 125 | super(OAuth_Client, self).put() 126 | -------------------------------------------------------------------------------- /oauth/utils.py: -------------------------------------------------------------------------------- 1 | from oauth.models import OAuth_Token 2 | 3 | def oauth_required(scope=None, realm='Example OAuth Service'): 4 | """ This is a decorator to be used with RequestHandler methods 5 | that accepts/requires OAuth to access a protected resource 6 | in accordance with Section 5 of the spec. 7 | 8 | If the token is valid, it's passed as a named parameter to 9 | the request handler. The request handler is responsible for 10 | validating the user associated with the token. """ 11 | def decorator(f): 12 | def wrapped_f(*args): 13 | request = args[0].request 14 | response = args[0].response 15 | 16 | def render_error(error, error_desc, error_uri=None): 17 | response.set_status({ 18 | 'invalid_request': 400, 19 | 'invalid_token': 401, 20 | 'expired_token': 401, 21 | 'insufficient_scope': 403, }[error]) 22 | authenticate_header = 'OAuth realm="%s", error="%s", error_description="%s"' % \ 23 | (realm, error, error_desc) 24 | if error_uri: 25 | authenticate_header += ', error_uri="%s"' % error_uri 26 | if scope: 27 | authenticate_header += ', scope="%s"' % scope 28 | response.headers['WWW-Authenticate'] = authenticate_header 29 | response.out.write(error_desc) 30 | 31 | if request.headers.get('Authorization', '').startswith('OAuth'): 32 | token = request.headers['Authorization'].split(' ')[1] 33 | else: 34 | token = request.get('oauth_token', None) 35 | 36 | if not token: 37 | render_error('invalid_request', "Not a valid request for an OAuth protected resource") 38 | return 39 | 40 | token = OAuth_Token.get_by_access_token(token) 41 | if token.is_expired(): 42 | if token.refresh_token: 43 | render_error('expired_token', "This token has expired") 44 | else: 45 | render_error('invalid_token', "This token is no longer valid") 46 | return 47 | 48 | if scope != token.scope: 49 | render_error('insufficient_scope', "This resource requires higher priveleges") 50 | return 51 | 52 | f(*args, token=token) 53 | return wrapped_f 54 | return decorator 55 | -------------------------------------------------------------------------------- /spec/oauth_server.rb: -------------------------------------------------------------------------------- 1 | require 'spec' 2 | require 'mechanize' 3 | require 'json' 4 | 5 | def server_url(path) 6 | "http://localhost:#{ENV['PORT']}#{path}" 7 | end 8 | 9 | CLIENT_ID = 'b3a8d6fbe43a3a95ac5a7bd95e0a89fcd4eb6302' 10 | CLIENT_SECRET = 'b6448e179471d13910829b9711ad06cd83f8f650' 11 | CLIENT_REDIRECT = 'http://localhost:8080' 12 | OWNER_USERNAME = 'ac123' 13 | OWNER_PASSWORD = '123' 14 | 15 | describe 'OAuth Access Token Endpoint' do 16 | it "lets client obtain access token with end-user credentials" do 17 | page = Mechanize.new.post(server_url("/oauth/token"), { 18 | "grant_type" => "password", 19 | "client_id" => CLIENT_ID, 20 | "client_secret" => CLIENT_SECRET, 21 | "username" => OWNER_USERNAME, 22 | "password" => OWNER_PASSWORD, 23 | }) 24 | response = JSON.parse(page.body) 25 | response.keys.should include("access_token") 26 | end 27 | 28 | it "lets client obtain access token with a refresh token" do 29 | page = Mechanize.new.post(server_url("/oauth/token"), { 30 | "grant_type" => "password", 31 | "client_id" => CLIENT_ID, 32 | "client_secret" => CLIENT_SECRET, 33 | "username" => OWNER_USERNAME, 34 | "password" => OWNER_PASSWORD, 35 | }) 36 | response = JSON.parse(page.body) 37 | refresh_token = response['refresh_token'] 38 | page = Mechanize.new.post(server_url("/oauth/token"), { 39 | "grant_type" => "refresh_token", 40 | "client_id" => CLIENT_ID, 41 | "client_secret" => CLIENT_SECRET, 42 | "refresh_token" => refresh_token, 43 | }) 44 | response = JSON.parse(page.body) 45 | response.keys.should include("access_token") 46 | end 47 | 48 | it "lets client obtain access token with only client credentials" do 49 | page = Mechanize.new.post(server_url("/oauth/token"), { 50 | "grant_type" => "client_credentials", 51 | "client_id" => CLIENT_ID, 52 | "client_secret" => CLIENT_SECRET, 53 | }) 54 | response = JSON.parse(page.body) 55 | response.keys.should include("access_token") 56 | end 57 | end 58 | 59 | describe 'OAuth Authorization Endpoint' do 60 | it "lets a client get end-user authorization" do 61 | page = Mechanize.new.post(server_url("/oauth/authorize"), { 62 | "response_type" => "code", 63 | "client_id" => CLIENT_ID, 64 | "redirect_uri" => CLIENT_REDIRECT, 65 | }) 66 | # do local app engine sdk auth 67 | 68 | 69 | page.body.should include(CLIENT_ID) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /templates/authorize.html: -------------------------------------------------------------------------------- 1 |
2 | Do you wish to allow the service named '{{client.name}}' to access this application on your behalf? 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
-------------------------------------------------------------------------------- /templates/clients.html: -------------------------------------------------------------------------------- 1 |

Clients

2 | 7 | Create Client 8 |
9 | Name:
10 | Redirect URI:
11 | 12 |
13 | -------------------------------------------------------------------------------- /tests/main_test.py: -------------------------------------------------------------------------------- 1 | from webtest import TestApp 2 | from main import application, ProtectedResourceHandler 3 | from oauth.models import OAuth_Client 4 | from google.appengine.api import apiproxy_stub_map, datastore_file_stub 5 | 6 | app = TestApp(application()) 7 | 8 | # clear datastore 9 | apiproxy_stub_map.apiproxy._APIProxyStubMap__stub_map['datastore_v3'].Clear() 10 | 11 | # set up test client 12 | client = OAuth_Client(name='test') 13 | client.put() 14 | 15 | def test_protected_resource_fail_naked(): 16 | response = app.get('/protected/resource', status=400) 17 | assert not ProtectedResourceHandler.SECRET_PAYLOAD in str(response) 18 | 19 | def test_protected_resource_success_flow(): 20 | response = app.post('/oauth/token', dict( 21 | grant_type = 'password', 22 | username = 'user', 23 | password = 'pass', 24 | client_id = client.client_id, 25 | client_secret = client.client_secret, 26 | scope = 'read', 27 | )) 28 | assert 'access_token' in response.json.keys() 29 | token = response.json['access_token'] 30 | response = app.get('/protected/resource', dict(oauth_token=token)) 31 | assert ProtectedResourceHandler.SECRET_PAYLOAD in str(response) --------------------------------------------------------------------------------