├── oauth
├── __init__.py
├── utils.py
├── models.py
└── handlers.py
├── app.yaml
├── templates
├── clients.html
└── authorize.html
├── index.yaml
├── tests
└── main_test.py
├── main.py
├── README.md
└── spec
└── oauth_server.rb
/oauth/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/templates/clients.html:
--------------------------------------------------------------------------------
1 |
Clients
2 |
3 | {% for client in clients %}
4 | - {{client.name}} : {{client.client_id}} : {{client.client_secret}} : {{client.redirect_uri}}
5 | {% endfor %}
6 |
7 | Create Client
8 |
13 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/templates/authorize.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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)
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------