| # 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. |
| |
| """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 FlowExchangeError(Error): |
| """Error trying to exchange an authorization grant for an access token.""" |
| pass |
| |
| |
| class AccessTokenRefreshError(Error): |
| """Error trying to refresh an expired access token.""" |
| pass |
| |
| |
| class AccessTokenCredentialsError(Error): |
| """Having only the access_token means no refresh is possible.""" |
| 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 Storage(object): |
| """Base class for all Storage objects. |
| |
| Store and retrieve a single credential. |
| """ |
| |
| |
| def get(self): |
| """Retrieve credential. |
| |
| Returns: |
| oauth2client.client.Credentials |
| """ |
| _abstract() |
| |
| def put(self, credentials): |
| """Write a credential. |
| |
| Args: |
| credentials: Credentials, the credentials to store. |
| """ |
| _abstract() |
| |
| |
| 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 |
| |
| # True if the credentials have been revoked or expired and can't be |
| # refreshed. |
| self._invalid = False |
| |
| @property |
| def invalid(self): |
| """True if the credentials are invalid, such as being revoked.""" |
| return getattr(self, '_invalid', False) |
| |
| 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' |
| } |
| logging.info("Refresing access_token") |
| 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: |
| # An {'error':...} response body means the token is expired or revoked, so |
| # we flag the credentials as such. |
| logging.error('Failed to retrieve access token: %s' % content) |
| error_msg = 'Invalid response %s.' % resp['status'] |
| try: |
| d = simplejson.loads(content) |
| if 'error' in d: |
| error_msg = d['error'] |
| self._invalid = True |
| if self.store is not None: |
| self.store(self) |
| else: |
| logging.warning("Unable to store refreshed credentials, no Storage provided.") |
| except: |
| pass |
| raise AccessTokenRefreshError(error_msg) |
| |
| def authorize(self, http): |
| """Authorize an httplib2.Http instance with these credentials. |
| |
| 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 = {} |
| headers['authorization'] = 'OAuth ' + 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: |
| logging.info("Refreshing because we got a 401") |
| self._refresh(request_orig) |
| headers['authorization'] = 'OAuth ' + self.access_token |
| return request_orig(uri, method, body, headers, |
| redirections, connection_type) |
| else: |
| return (resp, content) |
| |
| http.request = new_request |
| return http |
| |
| |
| class AccessTokenCredentials(OAuth2Credentials): |
| """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. This set of credentials is for the use case where you have |
| acquired an OAuth 2.0 access_token from another place such as a JavaScript |
| client or another web application, and wish to use it from Python. Because |
| only the access_token is present it can not be refreshed and will in time |
| expire. |
| |
| AccessTokenCredentials objects may be safely pickled and unpickled. |
| |
| Usage: |
| credentials = AccessTokenCredentials('<an access token>', |
| 'my-user-agent/1.0') |
| http = httplib2.Http() |
| http = credentials.authorize(http) |
| |
| Exceptions: |
| AccessTokenCredentialsExpired: raised when the access_token expires or is |
| revoked. |
| """ |
| |
| def __init__(self, access_token, user_agent): |
| """Create an instance of OAuth2Credentials |
| |
| This is one of the few types if Credentials that you should contrust, |
| Credentials objects are usually instantiated by a Flow. |
| |
| Args: |
| access_token: string, access 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. |
| """ |
| super(AccessTokenCredentials, self).__init__( |
| access_token, |
| None, |
| None, |
| None, |
| None, |
| None, |
| user_agent) |
| |
| def _refresh(self, http_request): |
| raise AccessTokenCredentialsError( |
| "The access_token is expired or invalid and can't be refreshed.") |
| |
| 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, |
| 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 |
| |
| 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.auth_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, http=None): |
| """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. |
| http: httplib2.Http, optional http instance to use to do the fetch |
| """ |
| |
| 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' |
| } |
| if http is None: |
| http = httplib2.Http() |
| resp, content = http.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) |
| error_msg = 'Invalid response %s.' % resp['status'] |
| try: |
| d = simplejson.loads(content) |
| if 'error' in d: |
| error_msg = d['error'] |
| except: |
| pass |
| |
| raise FlowExchangeError(error_msg) |