blob: 1bc2aaa0c4107e595d0f38680946978864dc3a18 [file] [log] [blame]
Joe Gregorio695fdc12011-01-16 16:46:55 -05001# Copyright (C) 2010 Google Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Utilities for Google App Engine
16
Joe Gregorio7c22ab22011-02-16 15:32:39 -050017Utilities for making it easier to use OAuth 2.0 on Google App Engine.
Joe Gregorio695fdc12011-01-16 16:46:55 -050018"""
19
20__author__ = 'jcgregorio@google.com (Joe Gregorio)'
21
Joe Gregorio1daa71b2011-09-15 18:12:14 -040022import base64
Joe Gregorio432f17e2011-05-22 23:18:00 -040023import httplib2
Joe Gregorio1daa71b2011-09-15 18:12:14 -040024import logging
Joe Gregorio695fdc12011-01-16 16:46:55 -050025import pickle
JacobMoshenko8e905102011-06-20 09:53:10 -040026import time
JacobMoshenko8e905102011-06-20 09:53:10 -040027
28try: # pragma: no cover
29 import simplejson
30except ImportError: # pragma: no cover
31 try:
32 # Try to import from django, should work on App Engine
33 from django.utils import simplejson
34 except ImportError:
35 # Should work for Python2.6 and higher.
36 import json as simplejson
Joe Gregorio695fdc12011-01-16 16:46:55 -050037
Joe Gregoriof08a4982011-10-07 13:11:16 -040038import clientsecrets
39
Joe Gregorio432f17e2011-05-22 23:18:00 -040040from client import AccessTokenRefreshError
JacobMoshenko8e905102011-06-20 09:53:10 -040041from client import AssertionCredentials
Joe Gregorio695fdc12011-01-16 16:46:55 -050042from client import Credentials
43from client import Flow
Joe Gregorio432f17e2011-05-22 23:18:00 -040044from client import OAuth2WebServerFlow
Joe Gregoriodeeb0202011-02-15 14:49:57 -050045from client import Storage
Joe Gregorio432f17e2011-05-22 23:18:00 -040046from google.appengine.api import memcache
47from google.appengine.api import users
JacobMoshenko8e905102011-06-20 09:53:10 -040048from google.appengine.api.app_identity import app_identity
Joe Gregorio432f17e2011-05-22 23:18:00 -040049from google.appengine.ext import db
50from google.appengine.ext import webapp
51from google.appengine.ext.webapp.util import login_required
52from google.appengine.ext.webapp.util import run_wsgi_app
Joe Gregorio695fdc12011-01-16 16:46:55 -050053
Joe Gregorio432f17e2011-05-22 23:18:00 -040054OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns'
Joe Gregorio695fdc12011-01-16 16:46:55 -050055
JacobMoshenko8e905102011-06-20 09:53:10 -040056
Joe Gregoriof08a4982011-10-07 13:11:16 -040057class InvalidClientSecretsError(Exception):
58 """The client_secrets.json file is malformed or missing required fields."""
59 pass
60
61
JacobMoshenko8e905102011-06-20 09:53:10 -040062class AppAssertionCredentials(AssertionCredentials):
63 """Credentials object for App Engine Assertion Grants
64
65 This object will allow an App Engine application to identify itself to Google
66 and other OAuth 2.0 servers that can verify assertions. It can be used for
67 the purpose of accessing data stored under an account assigned to the App
68 Engine application itself. The algorithm used for generating the assertion is
69 the Signed JSON Web Token (JWT) algorithm. Additional details can be found at
70 the following link:
71
72 http://self-issued.info/docs/draft-jones-json-web-token.html
73
74 This credential does not require a flow to instantiate because it represents
75 a two legged flow, and therefore has all of the required information to
76 generate and refresh its own access tokens.
77
JacobMoshenko8e905102011-06-20 09:53:10 -040078 """
79
JacobMoshenkocb6d8912011-07-08 13:35:15 -040080 def __init__(self, scope,
JacobMoshenko8e905102011-06-20 09:53:10 -040081 audience='https://accounts.google.com/o/oauth2/token',
82 assertion_type='http://oauth.net/grant_type/jwt/1.0/bearer',
83 token_uri='https://accounts.google.com/o/oauth2/token', **kwargs):
84 """Constructor for AppAssertionCredentials
85
86 Args:
87 scope: string, scope of the credentials being requested.
JacobMoshenko8e905102011-06-20 09:53:10 -040088 audience: string, The audience, or verifier of the assertion. For
89 convenience defaults to Google's audience.
90 assertion_type: string, Type name that will identify the format of the
91 assertion string. For convience, defaults to the JSON Web Token (JWT)
92 assertion type string.
93 token_uri: string, URI for token endpoint. For convenience
94 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
95 """
96 self.scope = scope
97 self.audience = audience
98 self.app_name = app_identity.get_service_account_name()
99
100 super(AppAssertionCredentials, self).__init__(
101 assertion_type,
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400102 None,
JacobMoshenko8e905102011-06-20 09:53:10 -0400103 token_uri)
104
Joe Gregorio562b7312011-09-15 09:06:38 -0400105 @classmethod
106 def from_json(cls, json):
107 data = simplejson.loads(json)
108 retval = AccessTokenCredentials(
109 data['scope'],
110 data['audience'],
111 data['assertion_type'],
112 data['token_uri'])
113 return retval
114
JacobMoshenko8e905102011-06-20 09:53:10 -0400115 def _generate_assertion(self):
116 header = {
117 'typ': 'JWT',
118 'alg': 'RS256',
119 }
120
121 now = int(time.time())
122 claims = {
123 'aud': self.audience,
124 'scope': self.scope,
125 'iat': now,
126 'exp': now + 3600,
127 'iss': self.app_name,
128 }
129
130 jwt_components = [base64.b64encode(simplejson.dumps(seg))
131 for seg in [header, claims]]
132
133 base_str = ".".join(jwt_components)
134 key_name, signature = app_identity.sign_blob(base_str)
135 jwt_components.append(base64.b64encode(signature))
136 return ".".join(jwt_components)
137
138
Joe Gregorio695fdc12011-01-16 16:46:55 -0500139class FlowProperty(db.Property):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500140 """App Engine datastore Property for Flow.
141
142 Utility property that allows easy storage and retreival of an
Joe Gregorio695fdc12011-01-16 16:46:55 -0500143 oauth2client.Flow"""
144
145 # Tell what the user type is.
146 data_type = Flow
147
148 # For writing to datastore.
149 def get_value_for_datastore(self, model_instance):
150 flow = super(FlowProperty,
151 self).get_value_for_datastore(model_instance)
152 return db.Blob(pickle.dumps(flow))
153
154 # For reading from datastore.
155 def make_value_from_datastore(self, value):
156 if value is None:
157 return None
158 return pickle.loads(value)
159
160 def validate(self, value):
161 if value is not None and not isinstance(value, Flow):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400162 raise db.BadValueError('Property %s must be convertible '
Joe Gregorio695fdc12011-01-16 16:46:55 -0500163 'to a FlowThreeLegged instance (%s)' %
164 (self.name, value))
165 return super(FlowProperty, self).validate(value)
166
167 def empty(self, value):
168 return not value
169
170
171class CredentialsProperty(db.Property):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500172 """App Engine datastore Property for Credentials.
173
174 Utility property that allows easy storage and retrieval of
Joe Gregorio695fdc12011-01-16 16:46:55 -0500175 oath2client.Credentials
176 """
177
178 # Tell what the user type is.
179 data_type = Credentials
180
181 # For writing to datastore.
182 def get_value_for_datastore(self, model_instance):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400183 logging.info("get: Got type " + str(type(model_instance)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500184 cred = super(CredentialsProperty,
185 self).get_value_for_datastore(model_instance)
Joe Gregorio562b7312011-09-15 09:06:38 -0400186 if cred is None:
187 cred = ''
188 else:
189 cred = cred.to_json()
190 return db.Blob(cred)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500191
192 # For reading from datastore.
193 def make_value_from_datastore(self, value):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400194 logging.info("make: Got type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500195 if value is None:
196 return None
Joe Gregorio562b7312011-09-15 09:06:38 -0400197 if len(value) == 0:
198 return None
Joe Gregorio562b7312011-09-15 09:06:38 -0400199 try:
200 credentials = Credentials.new_from_json(value)
201 except ValueError:
Joe Gregorioec555842011-10-27 11:10:39 -0400202 credentials = None
Joe Gregorio562b7312011-09-15 09:06:38 -0400203 return credentials
Joe Gregorio695fdc12011-01-16 16:46:55 -0500204
205 def validate(self, value):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400206 value = super(CredentialsProperty, self).validate(value)
207 logging.info("validate: Got type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500208 if value is not None and not isinstance(value, Credentials):
Joe Gregorio562b7312011-09-15 09:06:38 -0400209 raise db.BadValueError('Property %s must be convertible '
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400210 'to a Credentials instance (%s)' %
211 (self.name, value))
212 #if value is not None and not isinstance(value, Credentials):
213 # return None
214 return value
Joe Gregorio695fdc12011-01-16 16:46:55 -0500215
216
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500217class StorageByKeyName(Storage):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500218 """Store and retrieve a single credential to and from
219 the App Engine datastore.
220
221 This Storage helper presumes the Credentials
222 have been stored as a CredenialsProperty
223 on a datastore model class, and that entities
224 are stored by key_name.
225 """
226
Joe Gregorio432f17e2011-05-22 23:18:00 -0400227 def __init__(self, model, key_name, property_name, cache=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500228 """Constructor for Storage.
229
230 Args:
231 model: db.Model, model class
232 key_name: string, key name for the entity that has the credentials
JacobMoshenko8e905102011-06-20 09:53:10 -0400233 property_name: string, name of the property that is a CredentialsProperty
Joe Gregorio432f17e2011-05-22 23:18:00 -0400234 cache: memcache, a write-through cache to put in front of the datastore
Joe Gregorio695fdc12011-01-16 16:46:55 -0500235 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500236 self._model = model
237 self._key_name = key_name
238 self._property_name = property_name
Joe Gregorio432f17e2011-05-22 23:18:00 -0400239 self._cache = cache
Joe Gregorio695fdc12011-01-16 16:46:55 -0500240
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400241 def locked_get(self):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500242 """Retrieve Credential from datastore.
243
244 Returns:
245 oauth2client.Credentials
246 """
Joe Gregorio432f17e2011-05-22 23:18:00 -0400247 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400248 json = self._cache.get(self._key_name)
249 if json:
250 return Credentials.new_from_json(json)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500251 entity = self._model.get_or_insert(self._key_name)
252 credential = getattr(entity, self._property_name)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500253 if credential and hasattr(credential, 'set_store'):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400254 credential.set_store(self)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400255 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400256 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400257
Joe Gregorio695fdc12011-01-16 16:46:55 -0500258 return credential
259
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400260 def locked_put(self, credentials):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500261 """Write a Credentials to the datastore.
262
263 Args:
264 credentials: Credentials, the credentials to store.
265 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500266 entity = self._model.get_or_insert(self._key_name)
267 setattr(entity, self._property_name, credentials)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500268 entity.put()
Joe Gregorio432f17e2011-05-22 23:18:00 -0400269 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400270 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400271
272
273class CredentialsModel(db.Model):
274 """Storage for OAuth 2.0 Credentials
275
276 Storage of the model is keyed by the user.user_id().
277 """
278 credentials = CredentialsProperty()
279
280
281class OAuth2Decorator(object):
282 """Utility for making OAuth 2.0 easier.
283
284 Instantiate and then use with oauth_required or oauth_aware
285 as decorators on webapp.RequestHandler methods.
286
287 Example:
288
289 decorator = OAuth2Decorator(
290 client_id='837...ent.com',
291 client_secret='Qh...wwI',
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400292 scope='https://www.googleapis.com/auth/buzz')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400293
294
295 class MainHandler(webapp.RequestHandler):
296
297 @decorator.oauth_required
298 def get(self):
299 http = decorator.http()
300 # http is authorized with the user's Credentials and can be used
301 # in API calls
302
303 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400304
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400305 def __init__(self, client_id, client_secret, scope,
Joe Gregorio432f17e2011-05-22 23:18:00 -0400306 auth_uri='https://accounts.google.com/o/oauth2/auth',
Joe Gregoriof08a4982011-10-07 13:11:16 -0400307 token_uri='https://accounts.google.com/o/oauth2/token',
308 message=None):
Joe Gregorio432f17e2011-05-22 23:18:00 -0400309
310 """Constructor for OAuth2Decorator
311
312 Args:
313 client_id: string, client identifier.
314 client_secret: string client secret.
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400315 scope: string or list of strings, scope(s) of the credentials being
316 requested.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400317 auth_uri: string, URI for authorization endpoint. For convenience
318 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
319 token_uri: string, URI for token endpoint. For convenience
320 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregoriof08a4982011-10-07 13:11:16 -0400321 message: Message to display if there are problems with the OAuth 2.0
322 configuration. The message may contain HTML and will be presented on the
323 web interface for any method that uses the decorator.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400324 """
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400325 self.flow = OAuth2WebServerFlow(client_id, client_secret, scope, None,
326 auth_uri, token_uri)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400327 self.credentials = None
328 self._request_handler = None
Joe Gregoriof08a4982011-10-07 13:11:16 -0400329 self._message = message
330 self._in_error = False
331
332 def _display_error_message(self, request_handler):
333 request_handler.response.out.write('<html><body>')
334 request_handler.response.out.write(self._message)
335 request_handler.response.out.write('</body></html>')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400336
337 def oauth_required(self, method):
338 """Decorator that starts the OAuth 2.0 dance.
339
340 Starts the OAuth dance for the logged in user if they haven't already
341 granted access for this application.
342
343 Args:
344 method: callable, to be decorated method of a webapp.RequestHandler
345 instance.
346 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400347
Joe Gregorio432f17e2011-05-22 23:18:00 -0400348 def check_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400349 if self._in_error:
350 self._display_error_message(request_handler)
351 return
352
Joe Gregoriof427c532011-06-13 09:35:26 -0400353 user = users.get_current_user()
354 # Don't use @login_decorator as this could be used in a POST request.
355 if not user:
356 request_handler.redirect(users.create_login_url(
357 request_handler.request.uri))
358 return
Joe Gregorio432f17e2011-05-22 23:18:00 -0400359 # Store the request URI in 'state' so we can use it later
360 self.flow.params['state'] = request_handler.request.url
361 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400362 self.credentials = StorageByKeyName(
363 CredentialsModel, user.user_id(), 'credentials').get()
364
365 if not self.has_credentials():
366 return request_handler.redirect(self.authorize_url())
367 try:
368 method(request_handler, *args)
369 except AccessTokenRefreshError:
370 return request_handler.redirect(self.authorize_url())
371
372 return check_oauth
373
374 def oauth_aware(self, method):
375 """Decorator that sets up for OAuth 2.0 dance, but doesn't do it.
376
377 Does all the setup for the OAuth dance, but doesn't initiate it.
378 This decorator is useful if you want to create a page that knows
379 whether or not the user has granted access to this application.
380 From within a method decorated with @oauth_aware the has_credentials()
381 and authorize_url() methods can be called.
382
383 Args:
384 method: callable, to be decorated method of a webapp.RequestHandler
385 instance.
386 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400387
Joe Gregorio432f17e2011-05-22 23:18:00 -0400388 def setup_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400389 if self._in_error:
390 self._display_error_message(request_handler)
391 return
392
Joe Gregoriof427c532011-06-13 09:35:26 -0400393 user = users.get_current_user()
394 # Don't use @login_decorator as this could be used in a POST request.
395 if not user:
396 request_handler.redirect(users.create_login_url(
397 request_handler.request.uri))
398 return
Joe Gregoriof08a4982011-10-07 13:11:16 -0400399
400
Joe Gregorio432f17e2011-05-22 23:18:00 -0400401 self.flow.params['state'] = request_handler.request.url
402 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400403 self.credentials = StorageByKeyName(
404 CredentialsModel, user.user_id(), 'credentials').get()
405 method(request_handler, *args)
406 return setup_oauth
407
408 def has_credentials(self):
409 """True if for the logged in user there are valid access Credentials.
410
411 Must only be called from with a webapp.RequestHandler subclassed method
412 that had been decorated with either @oauth_required or @oauth_aware.
413 """
414 return self.credentials is not None and not self.credentials.invalid
415
416 def authorize_url(self):
417 """Returns the URL to start the OAuth dance.
418
419 Must only be called from with a webapp.RequestHandler subclassed method
420 that had been decorated with either @oauth_required or @oauth_aware.
421 """
422 callback = self._request_handler.request.relative_url('/oauth2callback')
423 url = self.flow.step1_get_authorize_url(callback)
424 user = users.get_current_user()
425 memcache.set(user.user_id(), pickle.dumps(self.flow),
426 namespace=OAUTH2CLIENT_NAMESPACE)
427 return url
428
429 def http(self):
430 """Returns an authorized http instance.
431
432 Must only be called from within an @oauth_required decorated method, or
433 from within an @oauth_aware decorated method where has_credentials()
434 returns True.
435 """
436 return self.credentials.authorize(httplib2.Http())
437
438
Joe Gregoriof08a4982011-10-07 13:11:16 -0400439class OAuth2DecoratorFromClientSecrets(OAuth2Decorator):
440 """An OAuth2Decorator that builds from a clientsecrets file.
441
442 Uses a clientsecrets file as the source for all the information when
443 constructing an OAuth2Decorator.
444
445 Example:
446
447 decorator = OAuth2DecoratorFromClientSecrets(
448 os.path.join(os.path.dirname(__file__), 'client_secrets.json')
449 scope='https://www.googleapis.com/auth/buzz')
450
451
452 class MainHandler(webapp.RequestHandler):
453
454 @decorator.oauth_required
455 def get(self):
456 http = decorator.http()
457 # http is authorized with the user's Credentials and can be used
458 # in API calls
459 """
460
461 def __init__(self, filename, scope, message=None):
462 """Constructor
463
464 Args:
465 filename: string, File name of client secrets.
466 scope: string, Space separated list of scopes.
467 message: string, A friendly string to display to the user if the
468 clientsecrets file is missing or invalid. The message may contain HTML and
469 will be presented on the web interface for any method that uses the
470 decorator.
471 """
472 try:
473 client_type, client_info = clientsecrets.loadfile(filename)
474 if client_type not in [clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]:
475 raise InvalidClientSecretsError('OAuth2Decorator doesn\'t support this OAuth 2.0 flow.')
476 super(OAuth2DecoratorFromClientSecrets,
477 self).__init__(
478 client_info['client_id'],
479 client_info['client_secret'],
480 scope,
481 client_info['auth_uri'],
482 client_info['token_uri'],
483 message)
484 except clientsecrets.InvalidClientSecretsError:
485 self._in_error = True
486 if message is not None:
487 self._message = message
488 else:
489 self._message = "Please configure your application for OAuth 2.0"
490
491
492def oauth2decorator_from_clientsecrets(filename, scope, message=None):
493 """Creates an OAuth2Decorator populated from a clientsecrets file.
494
495 Args:
496 filename: string, File name of client secrets.
497 scope: string, Space separated list of scopes.
498 message: string, A friendly string to display to the user if the
499 clientsecrets file is missing or invalid. The message may contain HTML and
500 will be presented on the web interface for any method that uses the
501 decorator.
502
503 Returns: An OAuth2Decorator
504
505 """
506 return OAuth2DecoratorFromClientSecrets(filename, scope, message)
507
508
Joe Gregorio432f17e2011-05-22 23:18:00 -0400509class OAuth2Handler(webapp.RequestHandler):
510 """Handler for the redirect_uri of the OAuth 2.0 dance."""
511
512 @login_required
513 def get(self):
514 error = self.request.get('error')
515 if error:
516 errormsg = self.request.get('error_description', error)
JacobMoshenko8e905102011-06-20 09:53:10 -0400517 self.response.out.write(
518 'The authorization request failed: %s' % errormsg)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400519 else:
520 user = users.get_current_user()
JacobMoshenko8e905102011-06-20 09:53:10 -0400521 flow = pickle.loads(memcache.get(user.user_id(),
522 namespace=OAUTH2CLIENT_NAMESPACE))
Joe Gregorio432f17e2011-05-22 23:18:00 -0400523 # This code should be ammended with application specific error
524 # handling. The following cases should be considered:
525 # 1. What if the flow doesn't exist in memcache? Or is corrupt?
526 # 2. What if the step2_exchange fails?
527 if flow:
528 credentials = flow.step2_exchange(self.request.params)
529 StorageByKeyName(
530 CredentialsModel, user.user_id(), 'credentials').put(credentials)
531 self.redirect(self.request.get('state'))
532 else:
533 # TODO Add error handling here.
534 pass
535
536
537application = webapp.WSGIApplication([('/oauth2callback', OAuth2Handler)])
538
JacobMoshenko8e905102011-06-20 09:53:10 -0400539
Joe Gregorio432f17e2011-05-22 23:18:00 -0400540def main():
541 run_wsgi_app(application)