blob: 3f2b72796a1bb60e45a2b4da63eff06c492ca472 [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 type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500196 if value is None:
197 return None
Joe Gregorio562b7312011-09-15 09:06:38 -0400198 if len(value) == 0:
199 return None
200 credentials = None
201 try:
202 credentials = Credentials.new_from_json(value)
203 except ValueError:
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400204 try:
205 credentials = pickle.loads(value)
206 except ValueError:
207 credentials = None
Joe Gregorio562b7312011-09-15 09:06:38 -0400208 return credentials
Joe Gregorio695fdc12011-01-16 16:46:55 -0500209
210 def validate(self, value):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400211 value = super(CredentialsProperty, self).validate(value)
212 logging.info("validate: Got type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500213 if value is not None and not isinstance(value, Credentials):
Joe Gregorio562b7312011-09-15 09:06:38 -0400214 raise db.BadValueError('Property %s must be convertible '
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400215 'to a Credentials instance (%s)' %
216 (self.name, value))
217 #if value is not None and not isinstance(value, Credentials):
218 # return None
219 return value
Joe Gregorio695fdc12011-01-16 16:46:55 -0500220
221
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500222class StorageByKeyName(Storage):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500223 """Store and retrieve a single credential to and from
224 the App Engine datastore.
225
226 This Storage helper presumes the Credentials
227 have been stored as a CredenialsProperty
228 on a datastore model class, and that entities
229 are stored by key_name.
230 """
231
Joe Gregorio432f17e2011-05-22 23:18:00 -0400232 def __init__(self, model, key_name, property_name, cache=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500233 """Constructor for Storage.
234
235 Args:
236 model: db.Model, model class
237 key_name: string, key name for the entity that has the credentials
JacobMoshenko8e905102011-06-20 09:53:10 -0400238 property_name: string, name of the property that is a CredentialsProperty
Joe Gregorio432f17e2011-05-22 23:18:00 -0400239 cache: memcache, a write-through cache to put in front of the datastore
Joe Gregorio695fdc12011-01-16 16:46:55 -0500240 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500241 self._model = model
242 self._key_name = key_name
243 self._property_name = property_name
Joe Gregorio432f17e2011-05-22 23:18:00 -0400244 self._cache = cache
Joe Gregorio695fdc12011-01-16 16:46:55 -0500245
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400246 def locked_get(self):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500247 """Retrieve Credential from datastore.
248
249 Returns:
250 oauth2client.Credentials
251 """
Joe Gregorio432f17e2011-05-22 23:18:00 -0400252 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400253 json = self._cache.get(self._key_name)
254 if json:
255 return Credentials.new_from_json(json)
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500256 entity = self._model.get_or_insert(self._key_name)
257 credential = getattr(entity, self._property_name)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500258 if credential and hasattr(credential, 'set_store'):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400259 credential.set_store(self)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400260 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400261 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400262
Joe Gregorio695fdc12011-01-16 16:46:55 -0500263 return credential
264
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400265 def locked_put(self, credentials):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500266 """Write a Credentials to the datastore.
267
268 Args:
269 credentials: Credentials, the credentials to store.
270 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500271 entity = self._model.get_or_insert(self._key_name)
272 setattr(entity, self._property_name, credentials)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500273 entity.put()
Joe Gregorio432f17e2011-05-22 23:18:00 -0400274 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400275 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400276
277
278class CredentialsModel(db.Model):
279 """Storage for OAuth 2.0 Credentials
280
281 Storage of the model is keyed by the user.user_id().
282 """
283 credentials = CredentialsProperty()
284
285
286class OAuth2Decorator(object):
287 """Utility for making OAuth 2.0 easier.
288
289 Instantiate and then use with oauth_required or oauth_aware
290 as decorators on webapp.RequestHandler methods.
291
292 Example:
293
294 decorator = OAuth2Decorator(
295 client_id='837...ent.com',
296 client_secret='Qh...wwI',
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400297 scope='https://www.googleapis.com/auth/buzz')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400298
299
300 class MainHandler(webapp.RequestHandler):
301
302 @decorator.oauth_required
303 def get(self):
304 http = decorator.http()
305 # http is authorized with the user's Credentials and can be used
306 # in API calls
307
308 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400309
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400310 def __init__(self, client_id, client_secret, scope,
Joe Gregorio432f17e2011-05-22 23:18:00 -0400311 auth_uri='https://accounts.google.com/o/oauth2/auth',
Joe Gregoriof08a4982011-10-07 13:11:16 -0400312 token_uri='https://accounts.google.com/o/oauth2/token',
313 message=None):
Joe Gregorio432f17e2011-05-22 23:18:00 -0400314
315 """Constructor for OAuth2Decorator
316
317 Args:
318 client_id: string, client identifier.
319 client_secret: string client secret.
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400320 scope: string or list of strings, scope(s) of the credentials being
321 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)