blob: 485f2e7aeb79cdd2774e3eaad0a1eeb12231e406 [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
78 AssertionFlowCredentials objects may be safely pickled and unpickled.
79 """
80
JacobMoshenkocb6d8912011-07-08 13:35:15 -040081 def __init__(self, scope,
JacobMoshenko8e905102011-06-20 09:53:10 -040082 audience='https://accounts.google.com/o/oauth2/token',
83 assertion_type='http://oauth.net/grant_type/jwt/1.0/bearer',
84 token_uri='https://accounts.google.com/o/oauth2/token', **kwargs):
85 """Constructor for AppAssertionCredentials
86
87 Args:
88 scope: string, scope of the credentials being requested.
JacobMoshenko8e905102011-06-20 09:53:10 -040089 audience: string, The audience, or verifier of the assertion. For
90 convenience defaults to Google's audience.
91 assertion_type: string, Type name that will identify the format of the
92 assertion string. For convience, defaults to the JSON Web Token (JWT)
93 assertion type string.
94 token_uri: string, URI for token endpoint. For convenience
95 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
96 """
97 self.scope = scope
98 self.audience = audience
99 self.app_name = app_identity.get_service_account_name()
100
101 super(AppAssertionCredentials, self).__init__(
102 assertion_type,
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400103 None,
JacobMoshenko8e905102011-06-20 09:53:10 -0400104 token_uri)
105
Joe Gregorio562b7312011-09-15 09:06:38 -0400106 @classmethod
107 def from_json(cls, json):
108 data = simplejson.loads(json)
109 retval = AccessTokenCredentials(
110 data['scope'],
111 data['audience'],
112 data['assertion_type'],
113 data['token_uri'])
114 return retval
115
JacobMoshenko8e905102011-06-20 09:53:10 -0400116 def _generate_assertion(self):
117 header = {
118 'typ': 'JWT',
119 'alg': 'RS256',
120 }
121
122 now = int(time.time())
123 claims = {
124 'aud': self.audience,
125 'scope': self.scope,
126 'iat': now,
127 'exp': now + 3600,
128 'iss': self.app_name,
129 }
130
131 jwt_components = [base64.b64encode(simplejson.dumps(seg))
132 for seg in [header, claims]]
133
134 base_str = ".".join(jwt_components)
135 key_name, signature = app_identity.sign_blob(base_str)
136 jwt_components.append(base64.b64encode(signature))
137 return ".".join(jwt_components)
138
139
Joe Gregorio695fdc12011-01-16 16:46:55 -0500140class FlowProperty(db.Property):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500141 """App Engine datastore Property for Flow.
142
143 Utility property that allows easy storage and retreival of an
Joe Gregorio695fdc12011-01-16 16:46:55 -0500144 oauth2client.Flow"""
145
146 # Tell what the user type is.
147 data_type = Flow
148
149 # For writing to datastore.
150 def get_value_for_datastore(self, model_instance):
151 flow = super(FlowProperty,
152 self).get_value_for_datastore(model_instance)
153 return db.Blob(pickle.dumps(flow))
154
155 # For reading from datastore.
156 def make_value_from_datastore(self, value):
157 if value is None:
158 return None
159 return pickle.loads(value)
160
161 def validate(self, value):
162 if value is not None and not isinstance(value, Flow):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400163 raise db.BadValueError('Property %s must be convertible '
Joe Gregorio695fdc12011-01-16 16:46:55 -0500164 'to a FlowThreeLegged instance (%s)' %
165 (self.name, value))
166 return super(FlowProperty, self).validate(value)
167
168 def empty(self, value):
169 return not value
170
171
172class CredentialsProperty(db.Property):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500173 """App Engine datastore Property for Credentials.
174
175 Utility property that allows easy storage and retrieval of
Joe Gregorio695fdc12011-01-16 16:46:55 -0500176 oath2client.Credentials
177 """
178
179 # Tell what the user type is.
180 data_type = Credentials
181
182 # For writing to datastore.
183 def get_value_for_datastore(self, model_instance):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400184 logging.info("get: Got type " + str(type(model_instance)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500185 cred = super(CredentialsProperty,
186 self).get_value_for_datastore(model_instance)
Joe Gregorio562b7312011-09-15 09:06:38 -0400187 if cred is None:
188 cred = ''
189 else:
190 cred = cred.to_json()
191 return db.Blob(cred)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500192
193 # For reading from datastore.
194 def make_value_from_datastore(self, value):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400195 logging.info("make: Got a " + value)
196 logging.info("make: Got type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500197 if value is None:
198 return None
Joe Gregorio562b7312011-09-15 09:06:38 -0400199 if len(value) == 0:
200 return None
201 credentials = None
202 try:
203 credentials = Credentials.new_from_json(value)
204 except ValueError:
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400205 try:
206 credentials = pickle.loads(value)
207 except ValueError:
208 credentials = None
Joe Gregorio562b7312011-09-15 09:06:38 -0400209 return credentials
Joe Gregorio695fdc12011-01-16 16:46:55 -0500210
211 def validate(self, value):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400212 value = super(CredentialsProperty, self).validate(value)
213 logging.info("validate: Got type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500214 if value is not None and not isinstance(value, Credentials):
Joe Gregorio562b7312011-09-15 09:06:38 -0400215 raise db.BadValueError('Property %s must be convertible '
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400216 'to a Credentials instance (%s)' %
217 (self.name, value))
218 #if value is not None and not isinstance(value, Credentials):
219 # return None
220 return value
Joe Gregorio695fdc12011-01-16 16:46:55 -0500221
222
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500223class StorageByKeyName(Storage):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500224 """Store and retrieve a single credential to and from
225 the App Engine datastore.
226
227 This Storage helper presumes the Credentials
228 have been stored as a CredenialsProperty
229 on a datastore model class, and that entities
230 are stored by key_name.
231 """
232
Joe Gregorio432f17e2011-05-22 23:18:00 -0400233 def __init__(self, model, key_name, property_name, cache=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500234 """Constructor for Storage.
235
236 Args:
237 model: db.Model, model class
238 key_name: string, key name for the entity that has the credentials
JacobMoshenko8e905102011-06-20 09:53:10 -0400239 property_name: string, name of the property that is a CredentialsProperty
Joe Gregorio432f17e2011-05-22 23:18:00 -0400240 cache: memcache, a write-through cache to put in front of the datastore
Joe Gregorio695fdc12011-01-16 16:46:55 -0500241 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500242 self._model = model
243 self._key_name = key_name
244 self._property_name = property_name
Joe Gregorio432f17e2011-05-22 23:18:00 -0400245 self._cache = cache
Joe Gregorio695fdc12011-01-16 16:46:55 -0500246
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400247 def locked_get(self):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500248 """Retrieve Credential from datastore.
249
250 Returns:
251 oauth2client.Credentials
252 """
Joe Gregorio432f17e2011-05-22 23:18:00 -0400253 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400254 json = self._cache.get(self._key_name)
255 if json:
256 return Credentials.new_from_json(json)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500257 entity = self._model.get_or_insert(self._key_name)
258 credential = getattr(entity, self._property_name)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500259 if credential and hasattr(credential, 'set_store'):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400260 credential.set_store(self)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400261 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400262 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400263
Joe Gregorio695fdc12011-01-16 16:46:55 -0500264 return credential
265
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400266 def locked_put(self, credentials):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500267 """Write a Credentials to the datastore.
268
269 Args:
270 credentials: Credentials, the credentials to store.
271 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500272 entity = self._model.get_or_insert(self._key_name)
273 setattr(entity, self._property_name, credentials)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500274 entity.put()
Joe Gregorio432f17e2011-05-22 23:18:00 -0400275 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400276 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400277
278
279class CredentialsModel(db.Model):
280 """Storage for OAuth 2.0 Credentials
281
282 Storage of the model is keyed by the user.user_id().
283 """
284 credentials = CredentialsProperty()
285
286
287class OAuth2Decorator(object):
288 """Utility for making OAuth 2.0 easier.
289
290 Instantiate and then use with oauth_required or oauth_aware
291 as decorators on webapp.RequestHandler methods.
292
293 Example:
294
295 decorator = OAuth2Decorator(
296 client_id='837...ent.com',
297 client_secret='Qh...wwI',
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400298 scope='https://www.googleapis.com/auth/buzz')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400299
300
301 class MainHandler(webapp.RequestHandler):
302
303 @decorator.oauth_required
304 def get(self):
305 http = decorator.http()
306 # http is authorized with the user's Credentials and can be used
307 # in API calls
308
309 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400310
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400311 def __init__(self, client_id, client_secret, scope,
Joe Gregorio432f17e2011-05-22 23:18:00 -0400312 auth_uri='https://accounts.google.com/o/oauth2/auth',
Joe Gregoriof08a4982011-10-07 13:11:16 -0400313 token_uri='https://accounts.google.com/o/oauth2/token',
314 message=None):
Joe Gregorio432f17e2011-05-22 23:18:00 -0400315
316 """Constructor for OAuth2Decorator
317
318 Args:
319 client_id: string, client identifier.
320 client_secret: string client secret.
321 scope: string, scope of the credentials being requested.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400322 auth_uri: string, URI for authorization endpoint. For convenience
323 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
324 token_uri: string, URI for token endpoint. For convenience
325 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregoriof08a4982011-10-07 13:11:16 -0400326 message: Message to display if there are problems with the OAuth 2.0
327 configuration. The message may contain HTML and will be presented on the
328 web interface for any method that uses the decorator.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400329 """
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400330 self.flow = OAuth2WebServerFlow(client_id, client_secret, scope, None,
331 auth_uri, token_uri)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400332 self.credentials = None
333 self._request_handler = None
Joe Gregoriof08a4982011-10-07 13:11:16 -0400334 self._message = message
335 self._in_error = False
336
337 def _display_error_message(self, request_handler):
338 request_handler.response.out.write('<html><body>')
339 request_handler.response.out.write(self._message)
340 request_handler.response.out.write('</body></html>')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400341
342 def oauth_required(self, method):
343 """Decorator that starts the OAuth 2.0 dance.
344
345 Starts the OAuth dance for the logged in user if they haven't already
346 granted access for this application.
347
348 Args:
349 method: callable, to be decorated method of a webapp.RequestHandler
350 instance.
351 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400352
Joe Gregorio432f17e2011-05-22 23:18:00 -0400353 def check_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400354 if self._in_error:
355 self._display_error_message(request_handler)
356 return
357
Joe Gregoriof427c532011-06-13 09:35:26 -0400358 user = users.get_current_user()
359 # Don't use @login_decorator as this could be used in a POST request.
360 if not user:
361 request_handler.redirect(users.create_login_url(
362 request_handler.request.uri))
363 return
Joe Gregorio432f17e2011-05-22 23:18:00 -0400364 # Store the request URI in 'state' so we can use it later
365 self.flow.params['state'] = request_handler.request.url
366 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400367 self.credentials = StorageByKeyName(
368 CredentialsModel, user.user_id(), 'credentials').get()
369
370 if not self.has_credentials():
371 return request_handler.redirect(self.authorize_url())
372 try:
373 method(request_handler, *args)
374 except AccessTokenRefreshError:
375 return request_handler.redirect(self.authorize_url())
376
377 return check_oauth
378
379 def oauth_aware(self, method):
380 """Decorator that sets up for OAuth 2.0 dance, but doesn't do it.
381
382 Does all the setup for the OAuth dance, but doesn't initiate it.
383 This decorator is useful if you want to create a page that knows
384 whether or not the user has granted access to this application.
385 From within a method decorated with @oauth_aware the has_credentials()
386 and authorize_url() methods can be called.
387
388 Args:
389 method: callable, to be decorated method of a webapp.RequestHandler
390 instance.
391 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400392
Joe Gregorio432f17e2011-05-22 23:18:00 -0400393 def setup_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400394 if self._in_error:
395 self._display_error_message(request_handler)
396 return
397
Joe Gregoriof427c532011-06-13 09:35:26 -0400398 user = users.get_current_user()
399 # Don't use @login_decorator as this could be used in a POST request.
400 if not user:
401 request_handler.redirect(users.create_login_url(
402 request_handler.request.uri))
403 return
Joe Gregoriof08a4982011-10-07 13:11:16 -0400404
405
Joe Gregorio432f17e2011-05-22 23:18:00 -0400406 self.flow.params['state'] = request_handler.request.url
407 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400408 self.credentials = StorageByKeyName(
409 CredentialsModel, user.user_id(), 'credentials').get()
410 method(request_handler, *args)
411 return setup_oauth
412
413 def has_credentials(self):
414 """True if for the logged in user there are valid access Credentials.
415
416 Must only be called from with a webapp.RequestHandler subclassed method
417 that had been decorated with either @oauth_required or @oauth_aware.
418 """
419 return self.credentials is not None and not self.credentials.invalid
420
421 def authorize_url(self):
422 """Returns the URL to start the OAuth dance.
423
424 Must only be called from with a webapp.RequestHandler subclassed method
425 that had been decorated with either @oauth_required or @oauth_aware.
426 """
427 callback = self._request_handler.request.relative_url('/oauth2callback')
428 url = self.flow.step1_get_authorize_url(callback)
429 user = users.get_current_user()
430 memcache.set(user.user_id(), pickle.dumps(self.flow),
431 namespace=OAUTH2CLIENT_NAMESPACE)
432 return url
433
434 def http(self):
435 """Returns an authorized http instance.
436
437 Must only be called from within an @oauth_required decorated method, or
438 from within an @oauth_aware decorated method where has_credentials()
439 returns True.
440 """
441 return self.credentials.authorize(httplib2.Http())
442
443
Joe Gregoriof08a4982011-10-07 13:11:16 -0400444class OAuth2DecoratorFromClientSecrets(OAuth2Decorator):
445 """An OAuth2Decorator that builds from a clientsecrets file.
446
447 Uses a clientsecrets file as the source for all the information when
448 constructing an OAuth2Decorator.
449
450 Example:
451
452 decorator = OAuth2DecoratorFromClientSecrets(
453 os.path.join(os.path.dirname(__file__), 'client_secrets.json')
454 scope='https://www.googleapis.com/auth/buzz')
455
456
457 class MainHandler(webapp.RequestHandler):
458
459 @decorator.oauth_required
460 def get(self):
461 http = decorator.http()
462 # http is authorized with the user's Credentials and can be used
463 # in API calls
464 """
465
466 def __init__(self, filename, scope, message=None):
467 """Constructor
468
469 Args:
470 filename: string, File name of client secrets.
471 scope: string, Space separated list of scopes.
472 message: string, A friendly string to display to the user if the
473 clientsecrets file is missing or invalid. The message may contain HTML and
474 will be presented on the web interface for any method that uses the
475 decorator.
476 """
477 try:
478 client_type, client_info = clientsecrets.loadfile(filename)
479 if client_type not in [clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]:
480 raise InvalidClientSecretsError('OAuth2Decorator doesn\'t support this OAuth 2.0 flow.')
481 super(OAuth2DecoratorFromClientSecrets,
482 self).__init__(
483 client_info['client_id'],
484 client_info['client_secret'],
485 scope,
486 client_info['auth_uri'],
487 client_info['token_uri'],
488 message)
489 except clientsecrets.InvalidClientSecretsError:
490 self._in_error = True
491 if message is not None:
492 self._message = message
493 else:
494 self._message = "Please configure your application for OAuth 2.0"
495
496
497def oauth2decorator_from_clientsecrets(filename, scope, message=None):
498 """Creates an OAuth2Decorator populated from a clientsecrets file.
499
500 Args:
501 filename: string, File name of client secrets.
502 scope: string, Space separated list of scopes.
503 message: string, A friendly string to display to the user if the
504 clientsecrets file is missing or invalid. The message may contain HTML and
505 will be presented on the web interface for any method that uses the
506 decorator.
507
508 Returns: An OAuth2Decorator
509
510 """
511 return OAuth2DecoratorFromClientSecrets(filename, scope, message)
512
513
Joe Gregorio432f17e2011-05-22 23:18:00 -0400514class OAuth2Handler(webapp.RequestHandler):
515 """Handler for the redirect_uri of the OAuth 2.0 dance."""
516
517 @login_required
518 def get(self):
519 error = self.request.get('error')
520 if error:
521 errormsg = self.request.get('error_description', error)
JacobMoshenko8e905102011-06-20 09:53:10 -0400522 self.response.out.write(
523 'The authorization request failed: %s' % errormsg)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400524 else:
525 user = users.get_current_user()
JacobMoshenko8e905102011-06-20 09:53:10 -0400526 flow = pickle.loads(memcache.get(user.user_id(),
527 namespace=OAUTH2CLIENT_NAMESPACE))
Joe Gregorio432f17e2011-05-22 23:18:00 -0400528 # This code should be ammended with application specific error
529 # handling. The following cases should be considered:
530 # 1. What if the flow doesn't exist in memcache? Or is corrupt?
531 # 2. What if the step2_exchange fails?
532 if flow:
533 credentials = flow.step2_exchange(self.request.params)
534 StorageByKeyName(
535 CredentialsModel, user.user_id(), 'credentials').put(credentials)
536 self.redirect(self.request.get('state'))
537 else:
538 # TODO Add error handling here.
539 pass
540
541
542application = webapp.WSGIApplication([('/oauth2callback', OAuth2Handler)])
543
JacobMoshenko8e905102011-06-20 09:53:10 -0400544
Joe Gregorio432f17e2011-05-22 23:18:00 -0400545def main():
546 run_wsgi_app(application)