blob: e4169e9de49d69110bb05b6c89a00520e9b02892 [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
Joe Gregoriof08a4982011-10-07 13:11:16 -040028import clientsecrets
29
Joe Gregorio549230c2012-01-11 10:38:05 -050030from anyjson import simplejson
Joe Gregorio432f17e2011-05-22 23:18:00 -040031from client import AccessTokenRefreshError
JacobMoshenko8e905102011-06-20 09:53:10 -040032from client import AssertionCredentials
Joe Gregorio695fdc12011-01-16 16:46:55 -050033from client import Credentials
34from client import Flow
Joe Gregorio432f17e2011-05-22 23:18:00 -040035from client import OAuth2WebServerFlow
Joe Gregoriodeeb0202011-02-15 14:49:57 -050036from client import Storage
Joe Gregorio432f17e2011-05-22 23:18:00 -040037from google.appengine.api import memcache
38from google.appengine.api import users
JacobMoshenko8e905102011-06-20 09:53:10 -040039from google.appengine.api.app_identity import app_identity
Joe Gregorio432f17e2011-05-22 23:18:00 -040040from google.appengine.ext import db
41from google.appengine.ext import webapp
42from google.appengine.ext.webapp.util import login_required
43from google.appengine.ext.webapp.util import run_wsgi_app
Joe Gregorio695fdc12011-01-16 16:46:55 -050044
Joe Gregorio432f17e2011-05-22 23:18:00 -040045OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns'
Joe Gregorio695fdc12011-01-16 16:46:55 -050046
JacobMoshenko8e905102011-06-20 09:53:10 -040047
Joe Gregoriof08a4982011-10-07 13:11:16 -040048class InvalidClientSecretsError(Exception):
49 """The client_secrets.json file is malformed or missing required fields."""
50 pass
51
52
JacobMoshenko8e905102011-06-20 09:53:10 -040053class AppAssertionCredentials(AssertionCredentials):
54 """Credentials object for App Engine Assertion Grants
55
56 This object will allow an App Engine application to identify itself to Google
57 and other OAuth 2.0 servers that can verify assertions. It can be used for
58 the purpose of accessing data stored under an account assigned to the App
59 Engine application itself. The algorithm used for generating the assertion is
60 the Signed JSON Web Token (JWT) algorithm. Additional details can be found at
61 the following link:
62
63 http://self-issued.info/docs/draft-jones-json-web-token.html
64
65 This credential does not require a flow to instantiate because it represents
66 a two legged flow, and therefore has all of the required information to
67 generate and refresh its own access tokens.
68
JacobMoshenko8e905102011-06-20 09:53:10 -040069 """
70
JacobMoshenkocb6d8912011-07-08 13:35:15 -040071 def __init__(self, scope,
JacobMoshenko8e905102011-06-20 09:53:10 -040072 audience='https://accounts.google.com/o/oauth2/token',
73 assertion_type='http://oauth.net/grant_type/jwt/1.0/bearer',
74 token_uri='https://accounts.google.com/o/oauth2/token', **kwargs):
75 """Constructor for AppAssertionCredentials
76
77 Args:
78 scope: string, scope of the credentials being requested.
JacobMoshenko8e905102011-06-20 09:53:10 -040079 audience: string, The audience, or verifier of the assertion. For
80 convenience defaults to Google's audience.
81 assertion_type: string, Type name that will identify the format of the
82 assertion string. For convience, defaults to the JSON Web Token (JWT)
83 assertion type string.
84 token_uri: string, URI for token endpoint. For convenience
85 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
86 """
87 self.scope = scope
88 self.audience = audience
89 self.app_name = app_identity.get_service_account_name()
90
91 super(AppAssertionCredentials, self).__init__(
92 assertion_type,
JacobMoshenkocb6d8912011-07-08 13:35:15 -040093 None,
JacobMoshenko8e905102011-06-20 09:53:10 -040094 token_uri)
95
Joe Gregorio562b7312011-09-15 09:06:38 -040096 @classmethod
97 def from_json(cls, json):
98 data = simplejson.loads(json)
99 retval = AccessTokenCredentials(
100 data['scope'],
101 data['audience'],
102 data['assertion_type'],
103 data['token_uri'])
104 return retval
105
JacobMoshenko8e905102011-06-20 09:53:10 -0400106 def _generate_assertion(self):
107 header = {
108 'typ': 'JWT',
109 'alg': 'RS256',
110 }
111
112 now = int(time.time())
113 claims = {
114 'aud': self.audience,
115 'scope': self.scope,
116 'iat': now,
117 'exp': now + 3600,
118 'iss': self.app_name,
119 }
120
121 jwt_components = [base64.b64encode(simplejson.dumps(seg))
122 for seg in [header, claims]]
123
124 base_str = ".".join(jwt_components)
125 key_name, signature = app_identity.sign_blob(base_str)
126 jwt_components.append(base64.b64encode(signature))
127 return ".".join(jwt_components)
128
129
Joe Gregorio695fdc12011-01-16 16:46:55 -0500130class FlowProperty(db.Property):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500131 """App Engine datastore Property for Flow.
132
133 Utility property that allows easy storage and retreival of an
Joe Gregorio695fdc12011-01-16 16:46:55 -0500134 oauth2client.Flow"""
135
136 # Tell what the user type is.
137 data_type = Flow
138
139 # For writing to datastore.
140 def get_value_for_datastore(self, model_instance):
141 flow = super(FlowProperty,
142 self).get_value_for_datastore(model_instance)
143 return db.Blob(pickle.dumps(flow))
144
145 # For reading from datastore.
146 def make_value_from_datastore(self, value):
147 if value is None:
148 return None
149 return pickle.loads(value)
150
151 def validate(self, value):
152 if value is not None and not isinstance(value, Flow):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400153 raise db.BadValueError('Property %s must be convertible '
Joe Gregorio695fdc12011-01-16 16:46:55 -0500154 'to a FlowThreeLegged instance (%s)' %
155 (self.name, value))
156 return super(FlowProperty, self).validate(value)
157
158 def empty(self, value):
159 return not value
160
161
162class CredentialsProperty(db.Property):
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500163 """App Engine datastore Property for Credentials.
164
165 Utility property that allows easy storage and retrieval of
Joe Gregorio695fdc12011-01-16 16:46:55 -0500166 oath2client.Credentials
167 """
168
169 # Tell what the user type is.
170 data_type = Credentials
171
172 # For writing to datastore.
173 def get_value_for_datastore(self, model_instance):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400174 logging.info("get: Got type " + str(type(model_instance)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500175 cred = super(CredentialsProperty,
176 self).get_value_for_datastore(model_instance)
Joe Gregorio562b7312011-09-15 09:06:38 -0400177 if cred is None:
178 cred = ''
179 else:
180 cred = cred.to_json()
181 return db.Blob(cred)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500182
183 # For reading from datastore.
184 def make_value_from_datastore(self, value):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400185 logging.info("make: Got type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500186 if value is None:
187 return None
Joe Gregorio562b7312011-09-15 09:06:38 -0400188 if len(value) == 0:
189 return None
Joe Gregorio562b7312011-09-15 09:06:38 -0400190 try:
191 credentials = Credentials.new_from_json(value)
192 except ValueError:
Joe Gregorioec555842011-10-27 11:10:39 -0400193 credentials = None
Joe Gregorio562b7312011-09-15 09:06:38 -0400194 return credentials
Joe Gregorio695fdc12011-01-16 16:46:55 -0500195
196 def validate(self, value):
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400197 value = super(CredentialsProperty, self).validate(value)
198 logging.info("validate: Got type " + str(type(value)))
Joe Gregorio695fdc12011-01-16 16:46:55 -0500199 if value is not None and not isinstance(value, Credentials):
Joe Gregorio562b7312011-09-15 09:06:38 -0400200 raise db.BadValueError('Property %s must be convertible '
Joe Gregorio1daa71b2011-09-15 18:12:14 -0400201 'to a Credentials instance (%s)' %
202 (self.name, value))
203 #if value is not None and not isinstance(value, Credentials):
204 # return None
205 return value
Joe Gregorio695fdc12011-01-16 16:46:55 -0500206
207
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500208class StorageByKeyName(Storage):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500209 """Store and retrieve a single credential to and from
210 the App Engine datastore.
211
212 This Storage helper presumes the Credentials
213 have been stored as a CredenialsProperty
214 on a datastore model class, and that entities
215 are stored by key_name.
216 """
217
Joe Gregorio432f17e2011-05-22 23:18:00 -0400218 def __init__(self, model, key_name, property_name, cache=None):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500219 """Constructor for Storage.
220
221 Args:
222 model: db.Model, model class
223 key_name: string, key name for the entity that has the credentials
JacobMoshenko8e905102011-06-20 09:53:10 -0400224 property_name: string, name of the property that is a CredentialsProperty
Joe Gregorio432f17e2011-05-22 23:18:00 -0400225 cache: memcache, a write-through cache to put in front of the datastore
Joe Gregorio695fdc12011-01-16 16:46:55 -0500226 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500227 self._model = model
228 self._key_name = key_name
229 self._property_name = property_name
Joe Gregorio432f17e2011-05-22 23:18:00 -0400230 self._cache = cache
Joe Gregorio695fdc12011-01-16 16:46:55 -0500231
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400232 def locked_get(self):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500233 """Retrieve Credential from datastore.
234
235 Returns:
236 oauth2client.Credentials
237 """
Joe Gregorio432f17e2011-05-22 23:18:00 -0400238 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400239 json = self._cache.get(self._key_name)
240 if json:
241 return Credentials.new_from_json(json)
Joe Gregorio9fa077c2011-11-18 08:16:52 -0500242
243 credential = None
244 entity = self._model.get_by_key_name(self._key_name)
245 if entity is not None:
246 credential = getattr(entity, self._property_name)
247 if credential and hasattr(credential, 'set_store'):
248 credential.set_store(self)
249 if self._cache:
250 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400251
Joe Gregorio695fdc12011-01-16 16:46:55 -0500252 return credential
253
Joe Gregoriod2ee4d82011-09-15 14:32:45 -0400254 def locked_put(self, credentials):
Joe Gregorio695fdc12011-01-16 16:46:55 -0500255 """Write a Credentials to the datastore.
256
257 Args:
258 credentials: Credentials, the credentials to store.
259 """
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500260 entity = self._model.get_or_insert(self._key_name)
261 setattr(entity, self._property_name, credentials)
Joe Gregorio695fdc12011-01-16 16:46:55 -0500262 entity.put()
Joe Gregorio432f17e2011-05-22 23:18:00 -0400263 if self._cache:
Joe Gregorio562b7312011-09-15 09:06:38 -0400264 self._cache.set(self._key_name, credentials.to_json())
Joe Gregorio432f17e2011-05-22 23:18:00 -0400265
266
267class CredentialsModel(db.Model):
268 """Storage for OAuth 2.0 Credentials
269
270 Storage of the model is keyed by the user.user_id().
271 """
272 credentials = CredentialsProperty()
273
274
275class OAuth2Decorator(object):
276 """Utility for making OAuth 2.0 easier.
277
278 Instantiate and then use with oauth_required or oauth_aware
279 as decorators on webapp.RequestHandler methods.
280
281 Example:
282
283 decorator = OAuth2Decorator(
284 client_id='837...ent.com',
285 client_secret='Qh...wwI',
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500286 scope='https://www.googleapis.com/auth/plus')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400287
288
289 class MainHandler(webapp.RequestHandler):
290
291 @decorator.oauth_required
292 def get(self):
293 http = decorator.http()
294 # http is authorized with the user's Credentials and can be used
295 # in API calls
296
297 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400298
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400299 def __init__(self, client_id, client_secret, scope,
Joe Gregorio432f17e2011-05-22 23:18:00 -0400300 auth_uri='https://accounts.google.com/o/oauth2/auth',
Joe Gregoriof08a4982011-10-07 13:11:16 -0400301 token_uri='https://accounts.google.com/o/oauth2/token',
Joe Gregorio1adde1a2012-01-06 12:30:35 -0500302 message=None, **kwargs):
Joe Gregorio432f17e2011-05-22 23:18:00 -0400303
304 """Constructor for OAuth2Decorator
305
306 Args:
307 client_id: string, client identifier.
308 client_secret: string client secret.
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400309 scope: string or list of strings, scope(s) of the credentials being
310 requested.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400311 auth_uri: string, URI for authorization endpoint. For convenience
312 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
313 token_uri: string, URI for token endpoint. For convenience
314 defaults to Google's endpoints but any OAuth 2.0 provider can be used.
Joe Gregoriof08a4982011-10-07 13:11:16 -0400315 message: Message to display if there are problems with the OAuth 2.0
316 configuration. The message may contain HTML and will be presented on the
317 web interface for any method that uses the decorator.
Joe Gregorio1adde1a2012-01-06 12:30:35 -0500318 **kwargs: dict, Keyword arguments are be passed along as kwargs to the
319 OAuth2WebServerFlow constructor.
Joe Gregorio432f17e2011-05-22 23:18:00 -0400320 """
JacobMoshenkocb6d8912011-07-08 13:35:15 -0400321 self.flow = OAuth2WebServerFlow(client_id, client_secret, scope, None,
Joe Gregorio1adde1a2012-01-06 12:30:35 -0500322 auth_uri, token_uri, **kwargs)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400323 self.credentials = None
324 self._request_handler = None
Joe Gregoriof08a4982011-10-07 13:11:16 -0400325 self._message = message
326 self._in_error = False
327
328 def _display_error_message(self, request_handler):
329 request_handler.response.out.write('<html><body>')
330 request_handler.response.out.write(self._message)
331 request_handler.response.out.write('</body></html>')
Joe Gregorio432f17e2011-05-22 23:18:00 -0400332
333 def oauth_required(self, method):
334 """Decorator that starts the OAuth 2.0 dance.
335
336 Starts the OAuth dance for the logged in user if they haven't already
337 granted access for this application.
338
339 Args:
340 method: callable, to be decorated method of a webapp.RequestHandler
341 instance.
342 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400343
Joe Gregorio432f17e2011-05-22 23:18:00 -0400344 def check_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400345 if self._in_error:
346 self._display_error_message(request_handler)
347 return
348
Joe Gregoriof427c532011-06-13 09:35:26 -0400349 user = users.get_current_user()
350 # Don't use @login_decorator as this could be used in a POST request.
351 if not user:
352 request_handler.redirect(users.create_login_url(
353 request_handler.request.uri))
354 return
Joe Gregorio432f17e2011-05-22 23:18:00 -0400355 # Store the request URI in 'state' so we can use it later
356 self.flow.params['state'] = request_handler.request.url
357 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400358 self.credentials = StorageByKeyName(
359 CredentialsModel, user.user_id(), 'credentials').get()
360
361 if not self.has_credentials():
362 return request_handler.redirect(self.authorize_url())
363 try:
364 method(request_handler, *args)
365 except AccessTokenRefreshError:
366 return request_handler.redirect(self.authorize_url())
367
368 return check_oauth
369
370 def oauth_aware(self, method):
371 """Decorator that sets up for OAuth 2.0 dance, but doesn't do it.
372
373 Does all the setup for the OAuth dance, but doesn't initiate it.
374 This decorator is useful if you want to create a page that knows
375 whether or not the user has granted access to this application.
376 From within a method decorated with @oauth_aware the has_credentials()
377 and authorize_url() methods can be called.
378
379 Args:
380 method: callable, to be decorated method of a webapp.RequestHandler
381 instance.
382 """
JacobMoshenko8e905102011-06-20 09:53:10 -0400383
Joe Gregorio432f17e2011-05-22 23:18:00 -0400384 def setup_oauth(request_handler, *args):
Joe Gregoriof08a4982011-10-07 13:11:16 -0400385 if self._in_error:
386 self._display_error_message(request_handler)
387 return
388
Joe Gregoriof427c532011-06-13 09:35:26 -0400389 user = users.get_current_user()
390 # Don't use @login_decorator as this could be used in a POST request.
391 if not user:
392 request_handler.redirect(users.create_login_url(
393 request_handler.request.uri))
394 return
Joe Gregoriof08a4982011-10-07 13:11:16 -0400395
396
Joe Gregorio432f17e2011-05-22 23:18:00 -0400397 self.flow.params['state'] = request_handler.request.url
398 self._request_handler = request_handler
Joe Gregorio432f17e2011-05-22 23:18:00 -0400399 self.credentials = StorageByKeyName(
400 CredentialsModel, user.user_id(), 'credentials').get()
401 method(request_handler, *args)
402 return setup_oauth
403
404 def has_credentials(self):
405 """True if for the logged in user there are valid access Credentials.
406
407 Must only be called from with a webapp.RequestHandler subclassed method
408 that had been decorated with either @oauth_required or @oauth_aware.
409 """
410 return self.credentials is not None and not self.credentials.invalid
411
412 def authorize_url(self):
413 """Returns the URL to start the OAuth dance.
414
415 Must only be called from with a webapp.RequestHandler subclassed method
416 that had been decorated with either @oauth_required or @oauth_aware.
417 """
418 callback = self._request_handler.request.relative_url('/oauth2callback')
419 url = self.flow.step1_get_authorize_url(callback)
420 user = users.get_current_user()
421 memcache.set(user.user_id(), pickle.dumps(self.flow),
422 namespace=OAUTH2CLIENT_NAMESPACE)
423 return url
424
425 def http(self):
426 """Returns an authorized http instance.
427
428 Must only be called from within an @oauth_required decorated method, or
429 from within an @oauth_aware decorated method where has_credentials()
430 returns True.
431 """
432 return self.credentials.authorize(httplib2.Http())
433
434
Joe Gregoriof08a4982011-10-07 13:11:16 -0400435class OAuth2DecoratorFromClientSecrets(OAuth2Decorator):
436 """An OAuth2Decorator that builds from a clientsecrets file.
437
438 Uses a clientsecrets file as the source for all the information when
439 constructing an OAuth2Decorator.
440
441 Example:
442
443 decorator = OAuth2DecoratorFromClientSecrets(
444 os.path.join(os.path.dirname(__file__), 'client_secrets.json')
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500445 scope='https://www.googleapis.com/auth/plus')
Joe Gregoriof08a4982011-10-07 13:11:16 -0400446
447
448 class MainHandler(webapp.RequestHandler):
449
450 @decorator.oauth_required
451 def get(self):
452 http = decorator.http()
453 # http is authorized with the user's Credentials and can be used
454 # in API calls
455 """
456
457 def __init__(self, filename, scope, message=None):
458 """Constructor
459
460 Args:
461 filename: string, File name of client secrets.
462 scope: string, Space separated list of scopes.
463 message: string, A friendly string to display to the user if the
464 clientsecrets file is missing or invalid. The message may contain HTML and
465 will be presented on the web interface for any method that uses the
466 decorator.
467 """
468 try:
469 client_type, client_info = clientsecrets.loadfile(filename)
470 if client_type not in [clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]:
471 raise InvalidClientSecretsError('OAuth2Decorator doesn\'t support this OAuth 2.0 flow.')
472 super(OAuth2DecoratorFromClientSecrets,
473 self).__init__(
474 client_info['client_id'],
475 client_info['client_secret'],
476 scope,
477 client_info['auth_uri'],
478 client_info['token_uri'],
479 message)
480 except clientsecrets.InvalidClientSecretsError:
481 self._in_error = True
482 if message is not None:
483 self._message = message
484 else:
485 self._message = "Please configure your application for OAuth 2.0"
486
487
488def oauth2decorator_from_clientsecrets(filename, scope, message=None):
489 """Creates an OAuth2Decorator populated from a clientsecrets file.
490
491 Args:
492 filename: string, File name of client secrets.
493 scope: string, Space separated list of scopes.
494 message: string, A friendly string to display to the user if the
495 clientsecrets file is missing or invalid. The message may contain HTML and
496 will be presented on the web interface for any method that uses the
497 decorator.
498
499 Returns: An OAuth2Decorator
500
501 """
502 return OAuth2DecoratorFromClientSecrets(filename, scope, message)
503
504
Joe Gregorio432f17e2011-05-22 23:18:00 -0400505class OAuth2Handler(webapp.RequestHandler):
506 """Handler for the redirect_uri of the OAuth 2.0 dance."""
507
508 @login_required
509 def get(self):
510 error = self.request.get('error')
511 if error:
512 errormsg = self.request.get('error_description', error)
JacobMoshenko8e905102011-06-20 09:53:10 -0400513 self.response.out.write(
514 'The authorization request failed: %s' % errormsg)
Joe Gregorio432f17e2011-05-22 23:18:00 -0400515 else:
516 user = users.get_current_user()
JacobMoshenko8e905102011-06-20 09:53:10 -0400517 flow = pickle.loads(memcache.get(user.user_id(),
518 namespace=OAUTH2CLIENT_NAMESPACE))
Joe Gregorio432f17e2011-05-22 23:18:00 -0400519 # This code should be ammended with application specific error
520 # handling. The following cases should be considered:
521 # 1. What if the flow doesn't exist in memcache? Or is corrupt?
522 # 2. What if the step2_exchange fails?
523 if flow:
524 credentials = flow.step2_exchange(self.request.params)
525 StorageByKeyName(
526 CredentialsModel, user.user_id(), 'credentials').put(credentials)
Joe Gregorioa07cb9c2011-12-07 10:51:45 -0500527 self.redirect(str(self.request.get('state')))
Joe Gregorio432f17e2011-05-22 23:18:00 -0400528 else:
529 # TODO Add error handling here.
530 pass
531
532
533application = webapp.WSGIApplication([('/oauth2callback', OAuth2Handler)])
534
JacobMoshenko8e905102011-06-20 09:53:10 -0400535
Joe Gregorio432f17e2011-05-22 23:18:00 -0400536def main():
537 run_wsgi_app(application)