diff --git a/apiclient/oauth.py b/apiclient/oauth.py index fe78e61..469b6e0 100644 --- a/apiclient/oauth.py +++ b/apiclient/oauth.py @@ -1,5 +1,3 @@ -#!/usr/bin/python2.4 -# # Copyright 2010 Google Inc. All Rights Reserved. """Utilities for OAuth. @@ -10,10 +8,14 @@ Utilities for making it easier to work with OAuth. __author__ = 'jcgregorio@google.com (Joe Gregorio)' import copy +import datetime import httplib2 +import logging import oauth2 as oauth import urllib -import logging +import urlparse + +from anyjson import simplejson try: from urlparse import parse_qs, parse_qsl @@ -77,6 +79,9 @@ class Credentials(object): """ _abstract() +class Flow(object): + """Base class for all Flow objects.""" + pass class OAuthCredentials(Credentials): """Credentials object for OAuth 1.0a @@ -148,7 +153,7 @@ class OAuthCredentials(Credentials): return http -class FlowThreeLegged(object): +class FlowThreeLegged(Flow): """Does the Three Legged Dance for OAuth 1.0a. """ @@ -249,3 +254,4 @@ class FlowThreeLegged(object): oauth_params['oauth_token_secret']) return OAuthCredentials(consumer, token, self.user_agent) + diff --git a/oauth2client/__init__.py b/oauth2client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oauth2client/appengine.py b/oauth2client/appengine.py new file mode 100644 index 0000000..80c6fab --- /dev/null +++ b/oauth2client/appengine.py @@ -0,0 +1,135 @@ +# Copyright (C) 2010 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utilities for Google App Engine + +Utilities for making it easier to use OAuth 2.0 +on Google App Engine. +""" + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' + +import pickle + +from google.appengine.ext import db +from client import Credentials +from client import Flow + + +class FlowProperty(db.Property): + """Utility property that allows easy + storage and retreival of an + oauth2client.Flow""" + + # Tell what the user type is. + data_type = Flow + + # For writing to datastore. + def get_value_for_datastore(self, model_instance): + flow = super(FlowProperty, + self).get_value_for_datastore(model_instance) + return db.Blob(pickle.dumps(flow)) + + # For reading from datastore. + def make_value_from_datastore(self, value): + if value is None: + return None + return pickle.loads(value) + + def validate(self, value): + if value is not None and not isinstance(value, Flow): + raise BadValueError('Property %s must be convertible ' + 'to a FlowThreeLegged instance (%s)' % + (self.name, value)) + return super(FlowProperty, self).validate(value) + + def empty(self, value): + return not value + + +class CredentialsProperty(db.Property): + """Utility property that allows easy + storage and retrieval of + oath2client.Credentials + """ + + # Tell what the user type is. + data_type = Credentials + + # For writing to datastore. + def get_value_for_datastore(self, model_instance): + cred = super(CredentialsProperty, + self).get_value_for_datastore(model_instance) + return db.Blob(pickle.dumps(cred)) + + # For reading from datastore. + def make_value_from_datastore(self, value): + if value is None: + return None + return pickle.loads(value) + + def validate(self, value): + if value is not None and not isinstance(value, Credentials): + raise BadValueError('Property %s must be convertible ' + 'to an Credentials instance (%s)' % + (self.name, value)) + return super(CredentialsProperty, self).validate(value) + + def empty(self, value): + return not value + + +class StorageByKeyName(object): + """Store and retrieve a single credential to and from + the App Engine datastore. + + This Storage helper presumes the Credentials + have been stored as a CredenialsProperty + on a datastore model class, and that entities + are stored by key_name. + """ + + def __init__(self, model, key_name, property_name): + """Constructor for Storage. + + Args: + model: db.Model, model class + key_name: string, key name for the entity that has the credentials + property_name: string, name of the property that is an CredentialsProperty + """ + self.model = model + self.key_name = key_name + self.property_name = property_name + + def get(self): + """Retrieve Credential from datastore. + + Returns: + oauth2client.Credentials + """ + entity = self.model.get_or_insert(self.key_name) + credential = getattr(entity, self.property_name) + if credential and hasattr(credential, 'set_store'): + credential.set_store(self.put) + return credential + + def put(self, credentials): + """Write a Credentials to the datastore. + + Args: + credentials: Credentials, the credentials to store. + """ + entity = self.model.get_or_insert(self.key_name) + setattr(entity, self.property_name, credentials) + entity.put() diff --git a/oauth2client/client.py b/oauth2client/client.py new file mode 100644 index 0000000..761cbec --- /dev/null +++ b/oauth2client/client.py @@ -0,0 +1,326 @@ +# Copyright 2010 Google Inc. All Rights Reserved. + +"""An OAuth 2.0 client + +Tools for interacting with OAuth 2.0 protected +resources. +""" + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' + +import copy +import datetime +import httplib2 +import logging +import urllib +import urlparse + +try: # pragma: no cover + import simplejson +except ImportError: # pragma: no cover + try: + # Try to import from django, should work on App Engine + from django.utils import simplejson + except ImportError: + # Should work for Python2.6 and higher. + import json as simplejson + +try: + from urlparse import parse_qsl +except ImportError: + from cgi import parse_qsl + + +class Error(Exception): + """Base error for this module.""" + pass + + +class RequestError(Error): + """Error occurred during request.""" + pass + + +class MissingParameter(Error): + pass + + +def _abstract(): + raise NotImplementedError('You need to override this function') + + +class Credentials(object): + """Base class for all Credentials objects. + + Subclasses must define an authorize() method + that applies the credentials to an HTTP transport. + """ + + def authorize(self, http): + """Take an httplib2.Http instance (or equivalent) and + authorizes it for the set of credentials, usually by + replacing http.request() with a method that adds in + the appropriate headers and then delegates to the original + Http.request() method. + """ + _abstract() + +class Flow(object): + """Base class for all Flow objects.""" + pass + + +class OAuth2Credentials(Credentials): + """Credentials object for OAuth 2.0 + + Credentials can be applied to an httplib2.Http object + using the authorize() method, which then signs each + request from that object with the OAuth 2.0 access token. + + OAuth2Credentials objects may be safely pickled and unpickled. + """ + + def __init__(self, access_token, client_id, client_secret, refresh_token, + token_expiry, token_uri, user_agent): + """Create an instance of OAuth2Credentials + + This constructor is not usually called by the user, instead + OAuth2Credentials objects are instantiated by + the OAuth2WebServerFlow. + + Args: + token_uri: string, URI of token endpoint + client_id: string, client identifier + client_secret: string, client secret + access_token: string, access token + token_expiry: datetime, when the access_token expires + refresh_token: string, refresh token + user_agent: string, The HTTP User-Agent to provide for this application. + + + Notes: + store: callable, a callable that when passed a Credential + will store the credential back to where it came from. + This is needed to store the latest access_token if it + has expired and been refreshed. + """ + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.refresh_token = refresh_token + self.store = None + self.token_expiry = token_expiry + self.token_uri = token_uri + self.user_agent = user_agent + + def set_store(self, store): + """Set the storage for the credential. + + Args: + store: callable, a callable that when passed a Credential + will store the credential back to where it came from. + This is needed to store the latest access_token if it + has expired and been refreshed. + """ + self.store = store + + def __getstate__(self): + """Trim the state down to something that can be pickled. + """ + d = copy.copy(self.__dict__) + del d['store'] + return d + + def __setstate__(self, state): + """Reconstitute the state of the object from being pickled. + """ + self.__dict__.update(state) + self.store = None + + def _refresh(self, http_request): + """Refresh the access_token using the refresh_token. + + Args: + http: An instance of httplib2.Http.request + or something that acts like it. + """ + body = urllib.urlencode({ + 'grant_type': 'refresh_token', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'refresh_token' : self.refresh_token + }) + headers = { + 'user-agent': self.user_agent, + 'content-type': 'application/x-www-form-urlencoded' + } + resp, content = http_request(self.token_uri, method='POST', body=body, headers=headers) + if resp.status == 200: + # TODO(jcgregorio) Raise an error if loads fails? + d = simplejson.loads(content) + self.access_token = d['access_token'] + self.refresh_token = d.get('refresh_token', self.refresh_token) + if 'expires_in' in d: + self.token_expiry = datetime.timedelta(seconds = int(d['expires_in'])) + datetime.datetime.now() + else: + self.token_expiry = None + if self.store is not None: + self.store(self) + else: + logging.error('Failed to retrieve access token: %s' % content) + raise RequestError('Invalid response %s.' % resp['status']) + + def authorize(self, http): + """ + Args: + http: An instance of httplib2.Http + or something that acts like it. + + Returns: + A modified instance of http that was passed in. + + Example: + + h = httplib2.Http() + h = credentials.authorize(h) + + You can't create a new OAuth + subclass of httplib2.Authenication because + it never gets passed the absolute URI, which is + needed for signing. So instead we have to overload + 'request' with a closure that adds in the + Authorization header and then calls the original version + of 'request()'. + """ + request_orig = http.request + + # The closure that will replace 'httplib2.Http.request'. + def new_request(uri, method='GET', body=None, headers=None, + redirections=httplib2.DEFAULT_MAX_REDIRECTS, + connection_type=None): + """Modify the request headers to add the appropriate + Authorization header.""" + if headers == None: + headers = {} + if ((self.token_expiry is not None) and (self.token_expiry <= datetime.datetime.now())): + logging.info("Refreshing because %s <= %s" %(self.token_expiry, datetime.datetime.now())) + self._refresh(request_orig) + headers['authorization'] = 'WRAP access_token=' + self.access_token + if 'user-agent' in headers: + headers['user-agent'] = self.user_agent + ' ' + headers['user-agent'] + else: + headers['user-agent'] = self.user_agent + resp, content = request_orig(uri, method, body, headers, + redirections, connection_type) + if resp.status == 401 and 'invalid_token' in resp.get('www-authenticate', ''): + logging.info("Refreshing because we got a 401") + self._refresh(request_orig) + return request_orig(uri, method, body, headers, + redirections, connection_type) + else: + return (resp, content) + + http.request = new_request + return http + + +class OAuth2WebServerFlow(Flow): + """Does the Web Server Flow for OAuth 2.0. + + OAuth2Credentials objects may be safely pickled and unpickled. + """ + + def __init__(self, client_id, client_secret, scope, user_agent, + authorization_uri='https://www.google.com/accounts/o8/oauth2/authorization', + token_uri='https://www.google.com/accounts/o8/oauth2/token', + **kwargs): + """Constructor for OAuth2WebServerFlow + + Args: + client_id: string, client identifier + client_secret: string client secret + scope: string, scope of the credentials being requested + user_agent: string, HTTP User-Agent to provide for this application. + authorization_uri: string, URI for authorization endpoint + token_uri: string, URI for token endpoint + **kwargs: dict, The keyword arguments are all optional and required + parameters for the OAuth calls. + """ + self.client_id = client_id + self.client_secret = client_secret + self.scope = scope + self.user_agent = user_agent + self.authorization_uri = authorization_uri + self.token_uri = token_uri + self.params = kwargs + self.redirect_uri = None + + def step1_get_authorize_url(self, redirect_uri='oob'): + """Returns a URI to redirect to the provider. + + Args: + redirect_uri: string, Either the string 'oob' for a non-web-based + application, or a URI that handles the callback from + the authorization server. + + If redirect_uri is 'oob' then pass in the + generated verification code to step2_exchange, + otherwise pass in the query parameters received + at the callback uri to step2_exchange. + """ + + self.redirect_uri = redirect_uri + query = { + 'response_type': 'code', + 'client_id': self.client_id, + 'redirect_uri': redirect_uri, + 'scope': self.scope, + } + query.update(self.params) + parts = list(urlparse.urlparse(self.authorization_uri)) + query.update(dict(parse_qsl(parts[4]))) # 4 is the index of the query part + parts[4] = urllib.urlencode(query) + return urlparse.urlunparse(parts) + + def step2_exchange(self, code): + """Exhanges a code for OAuth2Credentials. + + Args: + code: string or dict, either the code as a string, or a dictionary + of the query parameters to the redirect_uri, which contains + the code. + """ + + if not (isinstance(code, str) or isinstance(code, unicode)): + code = code['code'] + + body = urllib.urlencode({ + 'grant_type': 'authorization_code', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.redirect_uri, + 'scope': self.scope + }) + headers = { + 'user-agent': self.user_agent, + 'content-type': 'application/x-www-form-urlencoded' + } + h = httplib2.Http() + resp, content = h.request(self.token_uri, method='POST', body=body, headers=headers) + if resp.status == 200: + # TODO(jcgregorio) Raise an error if simplejson.loads fails? + d = simplejson.loads(content) + access_token = d['access_token'] + refresh_token = d.get('refresh_token', None) + token_expiry = None + if 'expires_in' in d: + token_expiry = datetime.datetime.now() + datetime.timedelta(seconds = int(d['expires_in'])) + + logging.info('Successfully retrieved access token: %s' % content) + return OAuth2Credentials(access_token, self.client_id, self.client_secret, + refresh_token, token_expiry, self.token_uri, + self.user_agent) + else: + logging.error('Failed to retrieve access token: %s' % content) + raise RequestError('Invalid response %s.' % resp['status']) diff --git a/oauth2client/django_orm.py b/oauth2client/django_orm.py new file mode 100644 index 0000000..50b3dd6 --- /dev/null +++ b/oauth2client/django_orm.py @@ -0,0 +1,38 @@ +from django.db import models + + +class CredentialsField(models.Field): + + __metaclass__ = models.SubfieldBase + + def db_type(self): + return 'VARCHAR' + + def to_python(self, value): + if value is None: + return None + if isinstance(value, oauth2client.Credentials): + return value + return pickle.loads(base64.b64decode(value)) + + def get_db_prep_value(self, value): + return base64.b64encode(pickle.dumps(value)) + + +class FlowField(models.Field): + + __metaclass__ = models.SubfieldBase + + def db_type(self): + return 'VARCHAR' + + def to_python(self, value): + print "In to_python", value + if value is None: + return None + if isinstance(value, oauth2client.Flow): + return value + return pickle.loads(base64.b64decode(value)) + + def get_db_prep_value(self, value): + return base64.b64encode(pickle.dumps(value)) diff --git a/oauth2client/file.py b/oauth2client/file.py new file mode 100644 index 0000000..e0e3997 --- /dev/null +++ b/oauth2client/file.py @@ -0,0 +1,42 @@ +# Copyright 2010 Google Inc. All Rights Reserved. + +"""Utilities for OAuth. + +Utilities for making it easier to work with OAuth 2.0 +credentials. +""" + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' + +import pickle + + +class Storage(object): + """Store and retrieve a single credential to and from a file.""" + + def __init__(self, filename): + self.filename = filename + + def get(self): + """Retrieve Credential from file. + + Returns: + apiclient.oauth.Credentials + """ + f = open(self.filename, 'r') + credentials = pickle.loads(f.read()) + f.close() + credentials.set_store(self.put) + return credentials + + def put(self, credentials): + """Write a pickled Credentials to file. + + Args: + credentials: Credentials, the credentials to store. + """ + f = open(self.filename, 'w') + f.write(pickle.dumps(credentials)) + f.close() + + diff --git a/oauth2client/tools.py b/oauth2client/tools.py new file mode 100644 index 0000000..57f6390 --- /dev/null +++ b/oauth2client/tools.py @@ -0,0 +1,136 @@ +# Copyright (C) 2010 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Command-line tools for authenticating via OAuth 2.0 + +Do the OAuth 2.0 Web Server dance for +a command line application. Stores the generated +credentials in a common file that is used by +other example apps in the same directory. +""" + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' +__all__ = ["run"] + +import socket +import sys +import BaseHTTPServer +import logging + +from optparse import OptionParser +from oauth2client.file import Storage + +try: + from urlparse import parse_qsl +except ImportError: + from cgi import parse_qsl + +# TODO(jcgregorio) +# - docs +# - error handling +# - oob when implemented + + +class ClientRedirectServer(BaseHTTPServer.HTTPServer): + """A server to handle OAuth 2.0 redirects back to localhost. + + Waits for a single request and parses the query parameters + into query_params and then stops serving. + """ + query_params = {} + +class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler): + """A handler for OAuth 2.0 redirects back to localhost. + + Waits for a single request and parses the query parameters + into the servers query_params and then stops serving. + """ + + def do_GET(s): + """Handle a GET request + + Checks the query parameters and if an error + occurred print a message of failure, otherwise + indicate success. + """ + s.send_response(200) + s.send_header("Content-type", "text/html") + s.end_headers() + query = s.path.split('?', 1)[-1] + query = dict(parse_qsl(query)) + s.server.query_params = query + s.wfile.write("
The authentication request failed.
") + else: + s.wfile.write("You have successfully authenticated
") + s.wfile.write("") + + def log_message(self, format, *args): + """Do not log messages to stdout while running as a command line program.""" + pass + +def run(flow, filename): + """Core code for a command-line application. + """ + parser = OptionParser() + parser.add_option("-f", "--file", dest="filename", + default=filename, help="write credentials to FILE", metavar="FILE") + parser.add_option("-p", "--no_local_web_server", dest="localhost", + action="store_false", + default=True, + help="Do not run a web server on localhost to handle redirect URIs") + parser.add_option("-w", "--local_web_server", dest="localhost", + action="store_true", + default=True, + help="Run a web server on localhost to handle redirect URIs") + + (options, args) = parser.parse_args() + + host_name = 'localhost' + port_numbers = [8080, 8090] + if options.localhost: + server_class = BaseHTTPServer.HTTPServer + try: + port_number = port_numbers[0] + httpd = server_class((host_name, port_number), ClientRedirectHandler) + except socket.error: + port_number = port_numbers[1] + try: + httpd = server_class((host_name, port_number), ClientRedirectHandler) + except socket.error: + options.localhost = False + + authorize_url = flow.step1_get_authorize_url('http://%s:%s/' % (host_name, port_number)) + + print 'Go to the following link in your browser:' + print authorize_url + print + + if options.localhost: + httpd.handle_request() + if 'error' in httpd.query_params: + sys.exit('Authentication request was rejected.') + if 'code' in httpd.query_params: + code = httpd.query_params['code'] + else: + accepted = 'n' + while accepted.lower() == 'n': + accepted = raw_input('Have you authorized me? (y/n) ') + code = raw_input('What is the verification code? ').strip() + + credentials = flow.step2_exchange(code) + + Storage(options.filename).put(credentials) + print "You have successfully authenticated." diff --git a/samples/oauth2/appengine/apiclient b/samples/oauth2/appengine/apiclient new file mode 120000 index 0000000..f53af07 --- /dev/null +++ b/samples/oauth2/appengine/apiclient @@ -0,0 +1 @@ +../../../apiclient/ \ No newline at end of file diff --git a/samples/oauth2/appengine/app.yaml b/samples/oauth2/appengine/app.yaml new file mode 100644 index 0000000..03bdf81 --- /dev/null +++ b/samples/oauth2/appengine/app.yaml @@ -0,0 +1,16 @@ +application: m-buzz +version: 1 +runtime: python +api_version: 1 + +handlers: +- url: /static + static_dir: static + +- url: /google8f1adb368b7bd14c.html + upload: google8f1adb368b7bd14c.html + static_files: static/google8f1adb368b7bd14c.html + +- url: .* + script: main.py + diff --git a/samples/oauth2/appengine/httplib2 b/samples/oauth2/appengine/httplib2 new file mode 120000 index 0000000..69b02ef --- /dev/null +++ b/samples/oauth2/appengine/httplib2 @@ -0,0 +1 @@ +../../../httplib2/ \ No newline at end of file diff --git a/samples/oauth2/appengine/index.yaml b/samples/oauth2/appengine/index.yaml new file mode 100644 index 0000000..a3b9e05 --- /dev/null +++ b/samples/oauth2/appengine/index.yaml @@ -0,0 +1,11 @@ +indexes: + +# AUTOGENERATED + +# This index.yaml is automatically updated whenever the dev_appserver +# detects that a new type of query is run. If you want to manage the +# index.yaml file manually, remove the above marker line (the line +# saying "# AUTOGENERATED"). If you want to manage some indexes +# manually, move them above the marker line. The index.yaml file is +# automatically uploaded to the admin console when you next deploy +# your application using appcfg.py. diff --git a/samples/oauth2/appengine/main.py b/samples/oauth2/appengine/main.py new file mode 100644 index 0000000..1c0676c --- /dev/null +++ b/samples/oauth2/appengine/main.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# +# Copyright 2007 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' + + +import httplib2 +import logging +import os +import pickle + +from apiclient.discovery import build +from oauth2client.appengine import CredentialsProperty +from oauth2client.appengine import StorageByKeyName +from oauth2client.client import OAuth2WebServerFlow +from google.appengine.api import memcache +from google.appengine.api import users +from google.appengine.ext import db +from google.appengine.ext import webapp +from google.appengine.ext.webapp import template +from google.appengine.ext.webapp import util +from google.appengine.ext.webapp.util import login_required + + +class Credentials(db.Model): + credentials = CredentialsProperty() + + +class MainHandler(webapp.RequestHandler): + + @login_required + def get(self): + user = users.get_current_user() + credentials = StorageByKeyName(Credentials, user.user_id(), 'credentials').get() + + if credentials: + http = httplib2.Http() + http = credentials.authorize(http) + p = build("buzz", "v1", http=http) + activities = p.activities() + activitylist = activities.list(scope='@consumption', + userId='@me').execute() + path = os.path.join(os.path.dirname(__file__), 'welcome.html') + logout = users.create_logout_url('/') + self.response.out.write( + template.render( + path, {'activitylist': activitylist, + 'logout': logout + })) + else: + flow = OAuth2WebServerFlow( + client_id='anonymous', + client_secret='anonymous', + scope='https://www.googleapis.com/auth/buzz', + user_agent='buzz-cmdline-sample/1.0', + domain='anonymous', + xoauth_displayname='Google App Engine Example App') + + callback = self.request.relative_url('/auth_return') + authorize_url = flow.step1_get_authorize_url(callback) + memcache.set(user.user_id(), pickle.dumps(flow)) + self.redirect(authorize_url) + + +class OAuthHandler(webapp.RequestHandler): + + @login_required + def get(self): + user = users.get_current_user() + flow = pickle.loads(memcache.get(user.user_id())) + if flow: + credentials = flow.step2_exchange(self.request.params) + StorageByKeyName(Credentials, user.user_id(), 'credentials').put(credentials) + self.redirect("/") + else: + pass + + +def main(): + application = webapp.WSGIApplication( + [ + ('/', MainHandler), + ('/auth_return', OAuthHandler) + ], + debug=True) + util.run_wsgi_app(application) + + +if __name__ == '__main__': + main() diff --git a/samples/oauth2/appengine/oauth2client b/samples/oauth2/appengine/oauth2client new file mode 120000 index 0000000..0a1ec33 --- /dev/null +++ b/samples/oauth2/appengine/oauth2client @@ -0,0 +1 @@ +../../../oauth2client/ \ No newline at end of file diff --git a/samples/oauth2/appengine/simplejson b/samples/oauth2/appengine/simplejson new file mode 120000 index 0000000..eeaa2f0 --- /dev/null +++ b/samples/oauth2/appengine/simplejson @@ -0,0 +1 @@ +../../../simplejson/ \ No newline at end of file diff --git a/samples/oauth2/appengine/uritemplate b/samples/oauth2/appengine/uritemplate new file mode 120000 index 0000000..5952908 --- /dev/null +++ b/samples/oauth2/appengine/uritemplate @@ -0,0 +1 @@ +../../../uritemplate/ \ No newline at end of file diff --git a/samples/oauth2/appengine/welcome.html b/samples/oauth2/appengine/welcome.html new file mode 100644 index 0000000..da40a16 --- /dev/null +++ b/samples/oauth2/appengine/welcome.html @@ -0,0 +1,29 @@ + + +|
+ + {{ item.actor.name }} |
+ + {{ item.object.content }} + | +
+
+ |
+