diff --git a/oauth2client/appengine.py b/oauth2client/appengine.py index 4a19754..485f2e7 100644 --- a/oauth2client/appengine.py +++ b/oauth2client/appengine.py @@ -35,6 +35,8 @@ except ImportError: # pragma: no cover # Should work for Python2.6 and higher. import json as simplejson +import clientsecrets + from client import AccessTokenRefreshError from client import AssertionCredentials from client import Credentials @@ -52,6 +54,11 @@ from google.appengine.ext.webapp.util import run_wsgi_app OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns' +class InvalidClientSecretsError(Exception): + """The client_secrets.json file is malformed or missing required fields.""" + pass + + class AppAssertionCredentials(AssertionCredentials): """Credentials object for App Engine Assertion Grants @@ -303,7 +310,8 @@ class OAuth2Decorator(object): def __init__(self, client_id, client_secret, scope, auth_uri='https://accounts.google.com/o/oauth2/auth', - token_uri='https://accounts.google.com/o/oauth2/token'): + token_uri='https://accounts.google.com/o/oauth2/token', + message=None): """Constructor for OAuth2Decorator @@ -315,11 +323,21 @@ class OAuth2Decorator(object): defaults to Google's endpoints but any OAuth 2.0 provider can be used. token_uri: string, URI for token endpoint. For convenience defaults to Google's endpoints but any OAuth 2.0 provider can be used. + message: Message to display if there are problems with the OAuth 2.0 + configuration. The message may contain HTML and will be presented on the + web interface for any method that uses the decorator. """ self.flow = OAuth2WebServerFlow(client_id, client_secret, scope, None, auth_uri, token_uri) self.credentials = None self._request_handler = None + self._message = message + self._in_error = False + + def _display_error_message(self, request_handler): + request_handler.response.out.write('
') + request_handler.response.out.write(self._message) + request_handler.response.out.write('') def oauth_required(self, method): """Decorator that starts the OAuth 2.0 dance. @@ -333,6 +351,10 @@ class OAuth2Decorator(object): """ def check_oauth(request_handler, *args): + if self._in_error: + self._display_error_message(request_handler) + return + user = users.get_current_user() # Don't use @login_decorator as this could be used in a POST request. if not user: @@ -369,12 +391,18 @@ class OAuth2Decorator(object): """ def setup_oauth(request_handler, *args): + if self._in_error: + self._display_error_message(request_handler) + return + user = users.get_current_user() # Don't use @login_decorator as this could be used in a POST request. if not user: request_handler.redirect(users.create_login_url( request_handler.request.uri)) return + + self.flow.params['state'] = request_handler.request.url self._request_handler = request_handler self.credentials = StorageByKeyName( @@ -413,6 +441,76 @@ class OAuth2Decorator(object): return self.credentials.authorize(httplib2.Http()) +class OAuth2DecoratorFromClientSecrets(OAuth2Decorator): + """An OAuth2Decorator that builds from a clientsecrets file. + + Uses a clientsecrets file as the source for all the information when + constructing an OAuth2Decorator. + + Example: + + decorator = OAuth2DecoratorFromClientSecrets( + os.path.join(os.path.dirname(__file__), 'client_secrets.json') + scope='https://www.googleapis.com/auth/buzz') + + + class MainHandler(webapp.RequestHandler): + + @decorator.oauth_required + def get(self): + http = decorator.http() + # http is authorized with the user's Credentials and can be used + # in API calls + """ + + def __init__(self, filename, scope, message=None): + """Constructor + + Args: + filename: string, File name of client secrets. + scope: string, Space separated list of scopes. + message: string, A friendly string to display to the user if the + clientsecrets file is missing or invalid. The message may contain HTML and + will be presented on the web interface for any method that uses the + decorator. + """ + try: + client_type, client_info = clientsecrets.loadfile(filename) + if client_type not in [clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]: + raise InvalidClientSecretsError('OAuth2Decorator doesn\'t support this OAuth 2.0 flow.') + super(OAuth2DecoratorFromClientSecrets, + self).__init__( + client_info['client_id'], + client_info['client_secret'], + scope, + client_info['auth_uri'], + client_info['token_uri'], + message) + except clientsecrets.InvalidClientSecretsError: + self._in_error = True + if message is not None: + self._message = message + else: + self._message = "Please configure your application for OAuth 2.0" + + +def oauth2decorator_from_clientsecrets(filename, scope, message=None): + """Creates an OAuth2Decorator populated from a clientsecrets file. + + Args: + filename: string, File name of client secrets. + scope: string, Space separated list of scopes. + message: string, A friendly string to display to the user if the + clientsecrets file is missing or invalid. The message may contain HTML and + will be presented on the web interface for any method that uses the + decorator. + + Returns: An OAuth2Decorator + + """ + return OAuth2DecoratorFromClientSecrets(filename, scope, message) + + class OAuth2Handler(webapp.RequestHandler): """Handler for the redirect_uri of the OAuth 2.0 dance.""" diff --git a/oauth2client/client.py b/oauth2client/client.py index 6392533..33e1ae0 100644 --- a/oauth2client/client.py +++ b/oauth2client/client.py @@ -19,10 +19,12 @@ Tools for interacting with OAuth 2.0 protected resources. __author__ = 'jcgregorio@google.com (Joe Gregorio)' +import clientsecrets import copy import datetime import httplib2 import logging +import sys import urllib import urlparse @@ -61,6 +63,10 @@ class AccessTokenRefreshError(Error): """Error trying to refresh an expired access token.""" pass +class UnknownClientSecretsFlowError(Error): + """The client secrets file called for an unknown type of OAuth 2.0 flow. """ + pass + class AccessTokenCredentialsError(Error): """Having only the access_token means no refresh is possible.""" @@ -610,7 +616,7 @@ class OAuth2WebServerFlow(Flow): OAuth2Credentials objects may be safely pickled and unpickled. """ - def __init__(self, client_id, client_secret, scope, user_agent, + def __init__(self, client_id, client_secret, scope, user_agent=None, auth_uri='https://accounts.google.com/o/oauth2/auth', token_uri='https://accounts.google.com/o/oauth2/token', **kwargs): @@ -721,3 +727,71 @@ class OAuth2WebServerFlow(Flow): pass raise FlowExchangeError(error_msg) + +def flow_from_clientsecrets(filename, scope, message=None): + """Create a Flow from a clientsecrets file. + + Will create the right kind of Flow based on the contents of the clientsecrets + file or will raise InvalidClientSecretsError for unknown types of Flows. + + Args: + filename: string, File name of client secrets. + scope: string, Space separated list of scopes. + message: string, A friendly string to display to the user if the + clientsecrets file is missing or invalid. If message is provided then + sys.exit will be called in the case of an error. If message in not + provided then clientsecrets.InvalidClientSecretsError will be raised. + + Returns: + A Flow object. + + Raises: + UnknownClientSecretsFlowError if the file describes an unknown kind of Flow. + clientsecrets.InvalidClientSecretsError if the clientsecrets file is + invalid. + """ + client_type, client_info = clientsecrets.loadfile(filename) + if client_type in [clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]: + return OAuth2WebServerFlow( + client_info['client_id'], + client_info['client_secret'], + scope, + None, # user_agent + client_info['auth_uri'], + client_info['token_uri']) + else: + raise UnknownClientSecretsFlowError( + 'This OAuth 2.0 flow is unsupported: "%s"' * client_type) + + +class OAuth2WebServerFlowFromClientSecrets(Flow): + """Does the Web Server Flow for OAuth 2.0. + + """ + + def __init__(self, client_secrets, scope, user_agent, + auth_uri='https://accounts.google.com/o/oauth2/auth', + token_uri='https://accounts.google.com/o/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. + auth_uri: string, URI for authorization endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 provider can be used. + token_uri: string, URI for token endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 provider can be used. + **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.auth_uri = auth_uri + self.token_uri = token_uri + self.params = kwargs + self.redirect_uri = None diff --git a/oauth2client/clientsecrets.py b/oauth2client/clientsecrets.py new file mode 100644 index 0000000..da48be7 --- /dev/null +++ b/oauth2client/clientsecrets.py @@ -0,0 +1,113 @@ +# Copyright (C) 2011 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 reading OAuth 2.0 client secret files. + +A client_secrets.json file contains all the information needed to interact with +an OAuth 2.0 protected service. +""" + +__author__ = 'jcgregorio@google.com (Joe Gregorio)' + + +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 + +# Properties that make a client_secrets.json file valid. +TYPE_WEB = 'web' +TYPE_INSTALLED = 'installed' + +VALID_CLIENT = { + TYPE_WEB: { + 'required': [ + 'client_id', + 'client_secret', + 'redirect_uris', + 'auth_uri', + 'token_uri'], + 'string': [ + 'client_id', + 'client_secret' + ] + }, + TYPE_INSTALLED: { + 'required': [ + 'client_id', + 'client_secret', + 'redirect_uris', + 'auth_uri', + 'token_uri'], + 'string': [ + 'client_id', + 'client_secret' + ] + } + } + +class Error(Exception): + """Base error for this module.""" + pass + + +class InvalidClientSecretsError(Error): + """Format of ClientSecrets file is invalid.""" + pass + + +def _validate_clientsecrets(obj): + if obj is None or len(obj) != 1: + raise InvalidClientSecretsError('Invalid file format.') + client_type = obj.keys()[0] + if client_type not in VALID_CLIENT.keys(): + raise InvalidClientSecretsError('Unknown client type: %s.' % client_type) + client_info = obj[client_type] + for prop_name in VALID_CLIENT[client_type]['required']: + if prop_name not in client_info: + raise InvalidClientSecretsError( + 'Missing property "%s" in a client type of "%s".' % (prop_name, + client_type)) + for prop_name in VALID_CLIENT[client_type]['string']: + if client_info[prop_name].startswith('[['): + raise InvalidClientSecretsError( + 'Property "%s" is not configured.' % prop_name) + return client_type, client_info + + +def load(fp): + obj = simplejson.load(fp) + return _validate_clientsecrets(obj) + + +def loads(s): + obj = simplejson.loads(s) + return _validate_clientsecrets(obj) + + +def loadfile(filename): + try: + fp = file(filename, 'r') + try: + obj = simplejson.load(fp) + finally: + fp.close() + except IOError: + raise InvalidClientSecretsError('File not found: "%s"' % filename) + return _validate_clientsecrets(obj) diff --git a/samples/appengine_with_decorator2/client_secrets.json b/samples/appengine_with_decorator2/client_secrets.json new file mode 100644 index 0000000..a232f37 --- /dev/null +++ b/samples/appengine_with_decorator2/client_secrets.json @@ -0,0 +1,9 @@ +{ + "web": { + "client_id": "[[INSERT CLIENT ID HERE]]", + "client_secret": "[[INSERT CLIENT SECRET HERE]]", + "redirect_uris": [], + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token" + } +} diff --git a/samples/appengine_with_decorator2/main.py b/samples/appengine_with_decorator2/main.py index 05fa922..443ff64 100644 --- a/samples/appengine_with_decorator2/main.py +++ b/samples/appengine_with_decorator2/main.py @@ -17,9 +17,9 @@ """Starting template for Google App Engine applications. Use this project as a starting point if you are just beginning to build a Google -App Engine project. Remember to fill in the OAuth 2.0 client_id and -client_secret which can be obtained from the Developer Console -+To make this sample run you will need to populate the client_secrets.json file +found at: +
+
+%s.
+
with information found on the APIs Console. +
+""" % CLIENT_SECRETS + http = httplib2.Http(memcache) service = build("buzz", "v1", http=http) - +decorator = oauth2decorator_from_clientsecrets( + CLIENT_SECRETS, + 'https://www.googleapis.com/auth/buzz', + MISSING_CLIENT_SECRETS_MESSAGE) class MainHandler(webapp.RequestHandler): diff --git a/samples/buzz/buzz.py b/samples/buzz/buzz.py index 8906048..6334ed6 100644 --- a/samples/buzz/buzz.py +++ b/samples/buzz/buzz.py @@ -38,31 +38,44 @@ __author__ = 'jcgregorio@google.com (Joe Gregorio)' import gflags import httplib2 import logging +import os import pprint import sys from apiclient.discovery import build from oauth2client.file import Storage from oauth2client.client import AccessTokenRefreshError -from oauth2client.client import OAuth2WebServerFlow +from oauth2client.client import flow_from_clientsecrets from oauth2client.tools import run + FLAGS = gflags.FLAGS -# Set up a Flow object to be used if we need to authenticate. This -# sample uses OAuth 2.0, and we set up the OAuth2WebServerFlow with -# the information it needs to authenticate. Note that it is called -# the Web Server Flow, but it can also handle the flow for native -# applications