blob: f1599bbb1fbdd8427ad97b6e4561c2c8d9ee6aa6 [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 Gregorio9fa077c2011-11-18 08:16:52 -0500251
252 credential = None
253 entity = self._model.get_by_key_name(self._key_name)
254 if entity is not None:
255 credential = getattr(entity, self._property_name)
256 if credential and hasattr(credential, 'set_store'):
257 credential.set_store(self)
258 if self._cache:
259 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400260
Joe Gregorio695fdc12011-01-16 16:46:55 -0500261 return credential
262
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400263 def locked_put(self, credentials):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500264 """Write a Credentials to the datastore.
265
266 Args:
267 credentials: Credentials, the credentials to store.
268 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500269 entity = self._model.get_or_insert(self._key_name)
270 setattr(entity, self._property_name, credentials)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500271 entity.put()
Joe Gregorio432f17e2011-05-22 23:18:00 -0400272 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400273 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400274
275
276class CredentialsModel(db.Model):
277 """Storage for OAuth 2.0 Credentials
278
279 Storage of the model is keyed by the user.user_id().
280 """
281 credentials = CredentialsProperty()
282
283
284class OAuth2Decorator(object):
285 """Utility for making OAuth 2.0 easier.
286
287 Instantiate and then use with oauth_required or oauth_aware
288 as decorators on webapp.RequestHandler methods.
289
290 Example:
291
292 decorator = OAuth2Decorator(
293 client_id='837...ent.com',
294 client_secret='Qh...wwI',
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500295 scope='https://www.googleapis.com/auth/plus')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400296
297
298 class MainHandler(webapp.RequestHandler):
299
300 @decorator.oauth_required
301 def get(self):
302 http = decorator.http()
303 # http is authorized with the user's Credentials and can be used
304 # in API calls
305
306 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400307
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400308 def __init__(self, client_id, client_secret, scope,
Joe Gregorio432f17e2011-05-22 23:18:00 -0400309 auth_uri='https://accounts.google.com/o/oauth2/auth',
Joe Gregoriof08a4982011-10-07 13:11:16 -0400310 token_uri='https://accounts.google.com/o/oauth2/token',
311 message=None):
Joe Gregorio432f17e2011-05-22 23:18:00 -0400312
313 """Constructor for OAuth2Decorator
314
315 Args:
316 client_id: string, client identifier.
317 client_secret: string client secret.
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400318 scope: string or list of strings, scope(s) of the credentials being
319 requested.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400320 auth_uri: string, URI for authorization endpoint. For convenience
321 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
322 token_uri: string, URI for token endpoint. For convenience
323 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregoriof08a4982011-10-07 13:11:16 -0400324 message: Message to display if there are problems with the OAuth 2.0
325 configuration. The message may contain HTML and will be presented on the
326 web interface for any method that uses the decorator.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400327 """
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400328 self.flow = OAuth2WebServerFlow(client_id, client_secret, scope, None,
329 auth_uri, token_uri)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400330 self.credentials = None
331 self._request_handler = None
Joe Gregoriof08a4982011-10-07 13:11:16 -0400332 self._message = message
333 self._in_error = False
334
335 def _display_error_message(self, request_handler):
336 request_handler.response.out.write('<html><body>')
337 request_handler.response.out.write(self._message)
338 request_handler.response.out.write('</body></html>')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400339
340 def oauth_required(self, method):
341 """Decorator that starts the OAuth 2.0 dance.
342
343 Starts the OAuth dance for the logged in user if they haven't already
344 granted access for this application.
345
346 Args:
347 method: callable, to be decorated method of a webapp.RequestHandler
348 instance.
349 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400350
Joe Gregorio432f17e2011-05-22 23:18:00 -0400351 def check_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400352 if self._in_error:
353 self._display_error_message(request_handler)
354 return
355
Joe Gregoriof427c532011-06-13 09:35:26 -0400356 user = users.get_current_user()
357 # Don't use @login_decorator as this could be used in a POST request.
358 if not user:
359 request_handler.redirect(users.create_login_url(
360 request_handler.request.uri))
361 return
Joe Gregorio432f17e2011-05-22 23:18:00 -0400362 # Store the request URI in 'state' so we can use it later
363 self.flow.params['state'] = request_handler.request.url
364 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400365 self.credentials = StorageByKeyName(
366 CredentialsModel, user.user_id(), 'credentials').get()
367
368 if not self.has_credentials():
369 return request_handler.redirect(self.authorize_url())
370 try:
371 method(request_handler, *args)
372 except AccessTokenRefreshError:
373 return request_handler.redirect(self.authorize_url())
374
375 return check_oauth
376
377 def oauth_aware(self, method):
378 """Decorator that sets up for OAuth 2.0 dance, but doesn't do it.
379
380 Does all the setup for the OAuth dance, but doesn't initiate it.
381 This decorator is useful if you want to create a page that knows
382 whether or not the user has granted access to this application.
383 From within a method decorated with @oauth_aware the has_credentials()
384 and authorize_url() methods can be called.
385
386 Args:
387 method: callable, to be decorated method of a webapp.RequestHandler
388 instance.
389 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400390
Joe Gregorio432f17e2011-05-22 23:18:00 -0400391 def setup_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400392 if self._in_error:
393 self._display_error_message(request_handler)
394 return
395
Joe Gregoriof427c532011-06-13 09:35:26 -0400396 user = users.get_current_user()
397 # Don't use @login_decorator as this could be used in a POST request.
398 if not user:
399 request_handler.redirect(users.create_login_url(
400 request_handler.request.uri))
401 return
Joe Gregoriof08a4982011-10-07 13:11:16 -0400402
403
Joe Gregorio432f17e2011-05-22 23:18:00 -0400404 self.flow.params['state'] = request_handler.request.url
405 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400406 self.credentials = StorageByKeyName(
407 CredentialsModel, user.user_id(), 'credentials').get()
408 method(request_handler, *args)
409 return setup_oauth
410
411 def has_credentials(self):
412 """True if for the logged in user there are valid access Credentials.
413
414 Must only be called from with a webapp.RequestHandler subclassed method
415 that had been decorated with either @oauth_required or @oauth_aware.
416 """
417 return self.credentials is not None and not self.credentials.invalid
418
419 def authorize_url(self):
420 """Returns the URL to start the OAuth dance.
421
422 Must only be called from with a webapp.RequestHandler subclassed method
423 that had been decorated with either @oauth_required or @oauth_aware.
424 """
425 callback = self._request_handler.request.relative_url('/oauth2callback')
426 url = self.flow.step1_get_authorize_url(callback)
427 user = users.get_current_user()
428 memcache.set(user.user_id(), pickle.dumps(self.flow),
429 namespace=OAUTH2CLIENT_NAMESPACE)
430 return url
431
432 def http(self):
433 """Returns an authorized http instance.
434
435 Must only be called from within an @oauth_required decorated method, or
436 from within an @oauth_aware decorated method where has_credentials()
437 returns True.
438 """
439 return self.credentials.authorize(httplib2.Http())
440
441
Joe Gregoriof08a4982011-10-07 13:11:16 -0400442class OAuth2DecoratorFromClientSecrets(OAuth2Decorator):
443 """An OAuth2Decorator that builds from a clientsecrets file.
444
445 Uses a clientsecrets file as the source for all the information when
446 constructing an OAuth2Decorator.
447
448 Example:
449
450 decorator = OAuth2DecoratorFromClientSecrets(
451 os.path.join(os.path.dirname(__file__), 'client_secrets.json')
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500452 scope='https://www.googleapis.com/auth/plus')
Joe Gregoriof08a4982011-10-07 13:11:16 -0400453
454
455 class MainHandler(webapp.RequestHandler):
456
457 @decorator.oauth_required
458 def get(self):
459 http = decorator.http()
460 # http is authorized with the user's Credentials and can be used
461 # in API calls
462 """
463
464 def __init__(self, filename, scope, message=None):
465 """Constructor
466
467 Args:
468 filename: string, File name of client secrets.
469 scope: string, Space separated list of scopes.
470 message: string, A friendly string to display to the user if the
471 clientsecrets file is missing or invalid. The message may contain HTML and
472 will be presented on the web interface for any method that uses the
473 decorator.
474 """
475 try:
476 client_type, client_info = clientsecrets.loadfile(filename)
477 if client_type not in [clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]:
478 raise InvalidClientSecretsError('OAuth2Decorator doesn\'t support this OAuth 2.0 flow.')
479 super(OAuth2DecoratorFromClientSecrets,
480 self).__init__(
481 client_info['client_id'],
482 client_info['client_secret'],
483 scope,
484 client_info['auth_uri'],
485 client_info['token_uri'],
486 message)
487 except clientsecrets.InvalidClientSecretsError:
488 self._in_error = True
489 if message is not None:
490 self._message = message
491 else:
492 self._message = "Please configure your application for OAuth 2.0"
493
494
495def oauth2decorator_from_clientsecrets(filename, scope, message=None):
496 """Creates an OAuth2Decorator populated from a clientsecrets file.
497
498 Args:
499 filename: string, File name of client secrets.
500 scope: string, Space separated list of scopes.
501 message: string, A friendly string to display to the user if the
502 clientsecrets file is missing or invalid. The message may contain HTML and
503 will be presented on the web interface for any method that uses the
504 decorator.
505
506 Returns: An OAuth2Decorator
507
508 """
509 return OAuth2DecoratorFromClientSecrets(filename, scope, message)
510
511
Joe Gregorio432f17e2011-05-22 23:18:00 -0400512class OAuth2Handler(webapp.RequestHandler):
513 """Handler for the redirect_uri of the OAuth 2.0 dance."""
514
515 @login_required
516 def get(self):
517 error = self.request.get('error')
518 if error:
519 errormsg = self.request.get('error_description', error)
JacobMoshenko8e905102011-06-20 09:53:10 -0400520 self.response.out.write(
521 'The authorization request failed: %s' % errormsg)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400522 else:
523 user = users.get_current_user()
JacobMoshenko8e905102011-06-20 09:53:10 -0400524 flow = pickle.loads(memcache.get(user.user_id(),
525 namespace=OAUTH2CLIENT_NAMESPACE))
Joe Gregorio432f17e2011-05-22 23:18:00 -0400526 # This code should be ammended with application specific error
527 # handling. The following cases should be considered:
528 # 1. What if the flow doesn't exist in memcache? Or is corrupt?
529 # 2. What if the step2_exchange fails?
530 if flow:
531 credentials = flow.step2_exchange(self.request.params)
532 StorageByKeyName(
533 CredentialsModel, user.user_id(), 'credentials').put(credentials)
Joe Gregorioa07cb9c2011-12-07 10:51:45 -0500534 self.redirect(str(self.request.get('state')))
Joe Gregorio432f17e2011-05-22 23:18:00 -0400535 else:
536 # TODO Add error handling here.
537 pass
538
539
540application = webapp.WSGIApplication([('/oauth2callback', OAuth2Handler)])
541
JacobMoshenko8e905102011-06-20 09:53:10 -0400542
Joe Gregorio432f17e2011-05-22 23:18:00 -0400543def main():
544 run_wsgi_app(application)