diff --git a/oauth2client/__init__.py b/oauth2client/__init__.py index 4802e90..13d949f 100644 --- a/oauth2client/__init__.py +++ b/oauth2client/__init__.py @@ -1 +1,5 @@ __version__ = "1.0" + +GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/auth' +GOOGLE_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke' +GOOGLE_TOKEN_URI = 'https://accounts.google.com/o/oauth2/token' diff --git a/oauth2client/appengine.py b/oauth2client/appengine.py index 95414c9..8e05d8b 100644 --- a/oauth2client/appengine.py +++ b/oauth2client/appengine.py @@ -35,6 +35,9 @@ from google.appengine.ext import ndb from google.appengine.ext import webapp from google.appengine.ext.webapp.util import login_required from google.appengine.ext.webapp.util import run_wsgi_app +from oauth2client import GOOGLE_AUTH_URI +from oauth2client import GOOGLE_REVOKE_URI +from oauth2client import GOOGLE_TOKEN_URI from oauth2client import clientsecrets from oauth2client import util from oauth2client import xsrfutil @@ -553,8 +556,9 @@ class OAuth2Decorator(object): @util.positional(4) 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', + auth_uri=GOOGLE_AUTH_URI, + token_uri=GOOGLE_TOKEN_URI, + revoke_uri=GOOGLE_REVOKE_URI, user_agent=None, message=None, callback_path='/oauth2callback', @@ -571,6 +575,8 @@ 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. + revoke_uri: string, URI for revoke endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 provider can be used. user_agent: string, User agent of your application, default to None. 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 @@ -588,6 +594,7 @@ class OAuth2Decorator(object): self._scope = util.scopes_to_string(scope) self._auth_uri = auth_uri self._token_uri = token_uri + self._revoke_uri = revoke_uri self._user_agent = user_agent self._kwargs = kwargs self._message = message @@ -655,8 +662,9 @@ class OAuth2Decorator(object): self._scope, redirect_uri=redirect_uri, user_agent=self._user_agent, auth_uri=self._auth_uri, - token_uri=self._token_uri, **self._kwargs) - + token_uri=self._token_uri, + revoke_uri=self._revoke_uri, + **self._kwargs) def oauth_aware(self, method): """Decorator that sets up for OAuth 2.0 dance, but doesn't do it. @@ -827,17 +835,21 @@ class OAuth2DecoratorFromClientSecrets(OAuth2Decorator): clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]: raise InvalidClientSecretsError( 'OAuth2Decorator doesn\'t support this OAuth 2.0 flow.') + constructor_kwargs = { + 'auth_uri': client_info['auth_uri'], + 'token_uri': client_info['token_uri'], + 'message': message, + } + revoke_uri = client_info.get('revoke_uri') + if revoke_uri is not None: + constructor_kwargs['revoke_uri'] = revoke_uri super(OAuth2DecoratorFromClientSecrets, self).__init__( - client_info['client_id'], - client_info['client_secret'], - scope, - auth_uri=client_info['auth_uri'], - token_uri=client_info['token_uri'], - message=message) + client_info['client_id'], client_info['client_secret'], + scope, **constructor_kwargs) if message is not None: self._message = message else: - self._message = "Please configure your application for OAuth 2.0" + self._message = 'Please configure your application for OAuth 2.0.' @util.positional(2) @@ -860,4 +872,4 @@ def oauth2decorator_from_clientsecrets(filename, scope, """ return OAuth2DecoratorFromClientSecrets(filename, scope, - message=message, cache=cache) + message=message, cache=cache) diff --git a/oauth2client/client.py b/oauth2client/client.py index 9ea30b7..1ad94e6 100644 --- a/oauth2client/client.py +++ b/oauth2client/client.py @@ -31,6 +31,9 @@ import time import urllib import urlparse +from oauth2client import GOOGLE_AUTH_URI +from oauth2client import GOOGLE_REVOKE_URI +from oauth2client import GOOGLE_TOKEN_URI from oauth2client import util from oauth2client.anyjson import simplejson @@ -63,36 +66,34 @@ REFRESH_STATUS_CODES = [401] 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 TokenRevokeError(Error): + """Error trying to revoke a token.""" + 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.""" - pass class VerifyJwtTokenError(Error): """Could on retrieve certificates for validation.""" - pass class NonAsciiHeaderError(Error): """Header names and values must be ASCII strings.""" - pass def _abstract(): @@ -128,11 +129,15 @@ class Credentials(object): NON_SERIALIZED_MEMBERS = ['store'] 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. + """Take an httplib2.Http instance (or equivalent) and authorizes it. + + 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. + + Args: + http: httplib2.Http, an http object to be used to make the refresh + request. """ _abstract() @@ -145,6 +150,15 @@ class Credentials(object): """ _abstract() + def revoke(self, http): + """Revokes a refresh_token and makes the credentials void. + + Args: + http: httplib2.Http, an http object to be used to make the revoke + request. + """ + _abstract() + def apply(self, headers): """Add the authorization to the headers. @@ -154,7 +168,7 @@ class Credentials(object): _abstract() def _to_json(self, strip): - """Utility function for creating a JSON representation of an instance of Credentials. + """Utility function that creates JSON repr. of a Credentials object. Args: strip: array, An array of names of members to not include in the JSON. @@ -347,6 +361,23 @@ def clean_headers(headers): return clean +def _update_query_params(uri, params): + """Updates a URI with new query parameters. + + Args: + uri: string, A valid URI, with potential existing query parameters. + params: dict, A dictionary of query parameters. + + Returns: + The same URI but with the new query parameters added. + """ + parts = list(urlparse.urlparse(uri)) + query_params = dict(parse_qsl(parts[4])) # 4 is the index of the query part + query_params.update(params) + parts[4] = urllib.urlencode(query_params) + return urlparse.urlunparse(parts) + + class OAuth2Credentials(Credentials): """Credentials object for OAuth 2.0. @@ -358,7 +389,8 @@ class OAuth2Credentials(Credentials): @util.positional(8) def __init__(self, access_token, client_id, client_secret, refresh_token, - token_expiry, token_uri, user_agent, id_token=None): + token_expiry, token_uri, user_agent, revoke_uri=None, + id_token=None): """Create an instance of OAuth2Credentials. This constructor is not usually called by the user, instead @@ -372,6 +404,8 @@ class OAuth2Credentials(Credentials): token_expiry: datetime, when the access_token expires. token_uri: string, URI of token endpoint. user_agent: string, The HTTP User-Agent to provide for this application. + revoke_uri: string, URI for revoke endpoint. Defaults to None; a token + can't be revoked if this is None. id_token: object, The identity of the resource owner. Notes: @@ -388,6 +422,7 @@ class OAuth2Credentials(Credentials): self.token_expiry = token_expiry self.token_uri = token_uri self.user_agent = user_agent + self.revoke_uri = revoke_uri self.id_token = id_token # True if the credentials have been revoked or expired and can't be @@ -405,7 +440,7 @@ class OAuth2Credentials(Credentials): Args: http: An instance of httplib2.Http - or something that acts like it. + or something that acts like it. Returns: A modified instance of http that was passed in. @@ -473,6 +508,15 @@ class OAuth2Credentials(Credentials): """ self._refresh(http.request) + def revoke(self, http): + """Revokes a refresh_token and makes the credentials void. + + Args: + http: httplib2.Http, an http object to be used to make the revoke + request. + """ + self._revoke(http.request) + def apply(self, headers): """Add the authorization to the headers. @@ -511,6 +555,7 @@ class OAuth2Credentials(Credentials): data['token_expiry'], data['token_uri'], data['user_agent'], + revoke_uri=data.get('revoke_uri', None), id_token=data.get('id_token', None)) retval.invalid = data['invalid'] return retval @@ -655,6 +700,46 @@ class OAuth2Credentials(Credentials): pass raise AccessTokenRefreshError(error_msg) + def _revoke(self, http_request): + """Revokes the refresh_token and deletes the store if available. + + Args: + http_request: callable, a callable that matches the method signature of + httplib2.Http.request, used to make the revoke request. + """ + self._do_revoke(http_request, self.refresh_token) + + def _do_revoke(self, http_request, token): + """Revokes the credentials and deletes the store if available. + + Args: + http_request: callable, a callable that matches the method signature of + httplib2.Http.request, used to make the refresh request. + token: A string used as the token to be revoked. Can be either an + access_token or refresh_token. + + Raises: + TokenRevokeError: If the revoke request does not return with a 200 OK. + """ + logger.info('Revoking token') + query_params = {'token': token} + token_revoke_uri = _update_query_params(self.revoke_uri, query_params) + resp, content = http_request(token_revoke_uri) + if resp.status == 200: + self.invalid = True + else: + error_msg = 'Invalid response %s.' % resp.status + try: + d = simplejson.loads(content) + if 'error' in d: + error_msg = d['error'] + except StandardError: + pass + raise TokenRevokeError(error_msg) + + if self.store: + self.store.delete() + class AccessTokenCredentials(OAuth2Credentials): """Credentials object for OAuth 2.0. @@ -681,7 +766,7 @@ class AccessTokenCredentials(OAuth2Credentials): revoked. """ - def __init__(self, access_token, user_agent): + def __init__(self, access_token, user_agent, revoke_uri=None): """Create an instance of OAuth2Credentials This is one of the few types if Credentials that you should contrust, @@ -690,10 +775,8 @@ class AccessTokenCredentials(OAuth2Credentials): 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. + revoke_uri: string, URI for revoke endpoint. Defaults to None; a token + can't be revoked if this is None. """ super(AccessTokenCredentials, self).__init__( access_token, @@ -702,7 +785,8 @@ class AccessTokenCredentials(OAuth2Credentials): None, None, None, - user_agent) + user_agent, + revoke_uri=revoke_uri) @classmethod @@ -715,7 +799,16 @@ class AccessTokenCredentials(OAuth2Credentials): def _refresh(self, http_request): raise AccessTokenCredentialsError( - "The access_token is expired or invalid and can't be refreshed.") + 'The access_token is expired or invalid and can\'t be refreshed.') + + def _revoke(self, http_request): + """Revokes the access_token and deletes the store if available. + + Args: + http_request: callable, a callable that matches the method signature of + httplib2.Http.request, used to make the revoke request. + """ + self._do_revoke(http_request, self.access_token) class AssertionCredentials(OAuth2Credentials): @@ -731,16 +824,18 @@ class AssertionCredentials(OAuth2Credentials): @util.positional(2) def __init__(self, assertion_type, user_agent=None, - token_uri='https://accounts.google.com/o/oauth2/token', + token_uri=GOOGLE_TOKEN_URI, + revoke_uri=GOOGLE_REVOKE_URI, **unused_kwargs): """Constructor for AssertionFlowCredentials. Args: assertion_type: string, assertion type that will be declared to the auth - server + server user_agent: string, The HTTP User-Agent to provide for this application. token_uri: string, URI for token endpoint. For convenience defaults to Google's endpoints but any OAuth 2.0 provider can be used. + revoke_uri: string, URI for revoke endpoint. """ super(AssertionCredentials, self).__init__( None, @@ -749,7 +844,8 @@ class AssertionCredentials(OAuth2Credentials): None, None, token_uri, - user_agent) + user_agent, + revoke_uri=revoke_uri) self.assertion_type = assertion_type def _generate_refresh_request_body(self): @@ -769,6 +865,16 @@ class AssertionCredentials(OAuth2Credentials): """ _abstract() + def _revoke(self, http_request): + """Revokes the access_token and deletes the store if available. + + Args: + http_request: callable, a callable that matches the method signature of + httplib2.Http.request, used to make the revoke request. + """ + self._do_revoke(http_request, self.access_token) + + if HAS_CRYPTO: # PyOpenSSL and PyCrypto are not prerequisites for oauth2client, so if it is # missing then don't create the SignedJwtAssertionCredentials or the @@ -794,7 +900,8 @@ if HAS_CRYPTO: scope, private_key_password='notasecret', user_agent=None, - token_uri='https://accounts.google.com/o/oauth2/token', + token_uri=GOOGLE_TOKEN_URI, + revoke_uri=GOOGLE_REVOKE_URI, **kwargs): """Constructor for SignedJwtAssertionCredentials. @@ -808,6 +915,7 @@ if HAS_CRYPTO: user_agent: string, HTTP User-Agent to provide for this application. token_uri: string, URI for token endpoint. For convenience defaults to Google's endpoints but any OAuth 2.0 provider can be used. + revoke_uri: string, URI for revoke endpoint. kwargs: kwargs, Additional parameters to add to the JWT token, for example prn=joe@xample.org.""" @@ -815,6 +923,7 @@ if HAS_CRYPTO: 'http://oauth.net/grant_type/jwt/1.0/bearer', user_agent=user_agent, token_uri=token_uri, + revoke_uri=revoke_uri, ) self.scope = util.scopes_to_string(scope) @@ -954,8 +1063,10 @@ def _parse_exchange_token_response(content): @util.positional(4) def credentials_from_code(client_id, client_secret, scope, code, - redirect_uri='postmessage', http=None, user_agent=None, - token_uri='https://accounts.google.com/o/oauth2/token'): + redirect_uri='postmessage', http=None, + user_agent=None, token_uri=GOOGLE_TOKEN_URI, + auth_uri=GOOGLE_AUTH_URI, + revoke_uri=GOOGLE_REVOKE_URI): """Exchanges an authorization code for an OAuth2Credentials object. Args: @@ -969,6 +1080,11 @@ def credentials_from_code(client_id, client_secret, scope, code, http: httplib2.Http, optional http instance to use to do the fetch token_uri: string, URI for token endpoint. For convenience defaults to Google's endpoints but any OAuth 2.0 provider can be used. + auth_uri: string, URI for authorization endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 provider can be used. + revoke_uri: string, URI for revoke endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 provider can be used. + Returns: An OAuth2Credentials object. @@ -978,8 +1094,8 @@ def credentials_from_code(client_id, client_secret, scope, code, """ flow = OAuth2WebServerFlow(client_id, client_secret, scope, redirect_uri=redirect_uri, user_agent=user_agent, - auth_uri='https://accounts.google.com/o/oauth2/auth', - token_uri=token_uri) + auth_uri=auth_uri, token_uri=token_uri, + revoke_uri=revoke_uri) credentials = flow.step2_exchange(code, http=http) return credentials @@ -1037,8 +1153,9 @@ class OAuth2WebServerFlow(Flow): def __init__(self, client_id, client_secret, scope, redirect_uri=None, user_agent=None, - auth_uri='https://accounts.google.com/o/oauth2/auth', - token_uri='https://accounts.google.com/o/oauth2/token', + auth_uri=GOOGLE_AUTH_URI, + token_uri=GOOGLE_TOKEN_URI, + revoke_uri=GOOGLE_REVOKE_URI, **kwargs): """Constructor for OAuth2WebServerFlow. @@ -1052,13 +1169,15 @@ class OAuth2WebServerFlow(Flow): scope: string or iterable of strings, scope(s) of the credentials being requested. redirect_uri: string, Either the string 'urn:ietf:wg:oauth:2.0:oob' for - a non-web-based application, or a URI that handles the callback from - the authorization server. + a non-web-based application, or a URI that handles the callback from + the authorization server. 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. + revoke_uri: string, URI for revoke 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. """ @@ -1069,10 +1188,11 @@ class OAuth2WebServerFlow(Flow): self.user_agent = user_agent self.auth_uri = auth_uri self.token_uri = token_uri + self.revoke_uri = revoke_uri self.params = { 'access_type': 'offline', 'response_type': 'code', - } + } self.params.update(kwargs) @util.positional(1) @@ -1081,9 +1201,9 @@ class OAuth2WebServerFlow(Flow): Args: redirect_uri: string, Either the string 'urn:ietf:wg:oauth:2.0:oob' for - a non-web-based application, or a URI that handles the callback from - the authorization server. This parameter is deprecated, please move to - passing the redirect_uri in via the constructor. + a non-web-based application, or a URI that handles the callback from + the authorization server. This parameter is deprecated, please move to + passing the redirect_uri in via the constructor. Returns: A URI as a string to redirect the user to begin the authorization flow. @@ -1097,16 +1217,13 @@ class OAuth2WebServerFlow(Flow): if self.redirect_uri is None: raise ValueError('The value of redirect_uri must not be None.') - query = { + query_params = { 'client_id': self.client_id, 'redirect_uri': self.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) + } + query_params.update(self.params) + return _update_query_params(self.auth_uri, query_params) @util.positional(2) def step2_exchange(self, code, http=None): @@ -1172,6 +1289,7 @@ class OAuth2WebServerFlow(Flow): return OAuth2Credentials(access_token, self.client_id, self.client_secret, refresh_token, token_expiry, self.token_uri, self.user_agent, + revoke_uri=self.revoke_uri, id_token=d.get('id_token', None)) else: logger.info('Failed to retrieve access token: %s' % content) @@ -1184,7 +1302,8 @@ class OAuth2WebServerFlow(Flow): @util.positional(2) -def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, cache=None): +def flow_from_clientsecrets(filename, scope, redirect_uri=None, + message=None, cache=None): """Create a Flow from a clientsecrets file. Will create the right kind of Flow based on the contents of the clientsecrets @@ -1194,8 +1313,8 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, ca filename: string, File name of client secrets. scope: string or iterable of strings, scope(s) to request. redirect_uri: string, Either the string 'urn:ietf:wg:oauth:2.0:oob' for - a non-web-based application, or a URI that handles the callback from - the authorization server. + a non-web-based application, or a URI that handles the callback from + the authorization server. 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 @@ -1213,15 +1332,18 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, ca """ try: client_type, client_info = clientsecrets.loadfile(filename, cache=cache) - if client_type in [clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED]: - return OAuth2WebServerFlow( - client_info['client_id'], - client_info['client_secret'], - scope, - redirect_uri=redirect_uri, - user_agent=None, - auth_uri=client_info['auth_uri'], - token_uri=client_info['token_uri']) + if client_type in (clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED): + constructor_kwargs = { + 'redirect_uri': redirect_uri, + 'auth_uri': client_info['auth_uri'], + 'token_uri': client_info['token_uri'], + } + revoke_uri = client_info.get('revoke_uri') + if revoke_uri is not None: + constructor_kwargs['revoke_uri'] = revoke_uri + return OAuth2WebServerFlow( + client_info['client_id'], client_info['client_secret'], + scope, **constructor_kwargs) except clientsecrets.InvalidClientSecretsError: if message: @@ -1230,4 +1352,4 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, ca raise else: raise UnknownClientSecretsFlowError( - 'This OAuth 2.0 flow is unsupported: "%s"' * client_type) + 'This OAuth 2.0 flow is unsupported: %r' % client_type) diff --git a/oauth2client/clientsecrets.py b/oauth2client/clientsecrets.py index 428c5ec..ac99aae 100644 --- a/oauth2client/clientsecrets.py +++ b/oauth2client/clientsecrets.py @@ -34,25 +34,28 @@ VALID_CLIENT = { 'client_secret', 'redirect_uris', 'auth_uri', - 'token_uri'], + 'token_uri', + ], 'string': [ 'client_id', - 'client_secret' - ] - }, + 'client_secret', + ], + }, TYPE_INSTALLED: { 'required': [ 'client_id', 'client_secret', 'redirect_uris', 'auth_uri', - 'token_uri'], + 'token_uri', + ], 'string': [ 'client_id', - 'client_secret' - ] - } - } + 'client_secret', + ], + }, +} + class Error(Exception): """Base error for this module.""" @@ -123,16 +126,16 @@ def loadfile(filename, cache=None): Args: filename: string, Path to a client_secrets.json file on a filesystem. - cache: An optional cache service client that implements get() and set() + cache: An optional cache service client that implements get() and set() methods. If not specified, the file is always being loaded from a filesystem. Raises: - InvalidClientSecretsError: In case of a validation error or some + InvalidClientSecretsError: In case of a validation error or some I/O failure. Can happen only on cache miss. Returns: - (client_type, client_info) tuple, as _loadfile() normally would. + (client_type, client_info) tuple, as _loadfile() normally would. JSON contents is validated only during first load. Cache hits are not validated. """ @@ -144,7 +147,7 @@ def loadfile(filename, cache=None): obj = cache.get(filename, namespace=_SECRET_NAMESPACE) if obj is None: client_type, client_info = _loadfile(filename) - obj = { client_type: client_info } + obj = {client_type: client_info} cache.set(filename, obj, namespace=_SECRET_NAMESPACE) return obj.iteritems().next() diff --git a/tests/data/client_secrets.json b/tests/data/client_secrets.json index dee5c6e..fd96a7a 100644 --- a/tests/data/client_secrets.json +++ b/tests/data/client_secrets.json @@ -4,6 +4,7 @@ "client_secret": "foo_client_secret", "redirect_uris": [], "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", + "revoke_uri": "https://accounts.google.com/o/oauth2/revoke" } } diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2c28cd2..1c8b706 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -57,6 +57,7 @@ from apiclient.http import MediaIoBaseUpload from apiclient.http import MediaUpload from apiclient.http import MediaUploadProgress from apiclient.http import tunnel_patch +from oauth2client import GOOGLE_TOKEN_URI from oauth2client.anyjson import simplejson from oauth2client.client import OAuth2Credentials import uritemplate @@ -896,14 +897,12 @@ class Discovery(unittest.TestCase): client_secret = 'cOuDdkfjxxnv+' refresh_token = '1/0/a.df219fjls0' token_expiry = datetime.datetime.utcnow() - token_uri = 'https://www.google.com/accounts/o8/oauth2/token' user_agent = 'refresh_checker/1.0' return OAuth2Credentials( access_token, client_id, client_secret, - refresh_token, token_expiry, token_uri, + refresh_token, token_expiry, GOOGLE_TOKEN_URI, user_agent) - def test_pickle_with_credentials(self): credentials = self._dummy_token() http = self._dummy_zoo_request() diff --git a/tests/test_oauth2client.py b/tests/test_oauth2client.py index 37e69ea..6dc2729 100644 --- a/tests/test_oauth2client.py +++ b/tests/test_oauth2client.py @@ -29,13 +29,10 @@ import os import unittest import urlparse -try: - from urlparse import parse_qs -except ImportError: - from cgi import parse_qs - from apiclient.http import HttpMock from apiclient.http import HttpMockSequence +from oauth2client import GOOGLE_REVOKE_URI +from oauth2client import GOOGLE_TOKEN_URI from oauth2client.anyjson import simplejson from oauth2client.client import AccessTokenCredentials from oauth2client.client import AccessTokenCredentialsError @@ -49,12 +46,17 @@ from oauth2client.client import OAuth2Credentials from oauth2client.client import OAuth2WebServerFlow from oauth2client.client import OOB_CALLBACK_URN from oauth2client.client import REFRESH_STATUS_CODES +from oauth2client.client import Storage +from oauth2client.client import TokenRevokeError from oauth2client.client import VerifyJwtTokenError from oauth2client.client import _extract_id_token +from oauth2client.client import _update_query_params from oauth2client.client import credentials_from_clientsecrets_and_code from oauth2client.client import credentials_from_code from oauth2client.client import flow_from_clientsecrets from oauth2client.clientsecrets import _loadfile +from test_discovery import assertUrisEqual + DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') @@ -89,20 +91,54 @@ class CredentialsTests(unittest.TestCase): restored = Credentials.new_from_json(json) +class DummyDeleteStorage(Storage): + delete_called = False + + def locked_delete(self): + self.delete_called = True + + +def _token_revoke_test_helper(testcase, status, revoke_raise, + valid_bool_value, token_attr): + current_store = getattr(testcase.credentials, 'store', None) + + dummy_store = DummyDeleteStorage() + testcase.credentials.set_store(dummy_store) + + actual_do_revoke = testcase.credentials._do_revoke + testcase.token_from_revoke = None + def do_revoke_stub(http_request, token): + testcase.token_from_revoke = token + return actual_do_revoke(http_request, token) + testcase.credentials._do_revoke = do_revoke_stub + + http = HttpMock(headers={'status': status}) + if revoke_raise: + testcase.assertRaises(TokenRevokeError, testcase.credentials.revoke, http) + else: + testcase.credentials.revoke(http) + + testcase.assertEqual(getattr(testcase.credentials, token_attr), + testcase.token_from_revoke) + testcase.assertEqual(valid_bool_value, testcase.credentials.invalid) + testcase.assertEqual(valid_bool_value, dummy_store.delete_called) + + testcase.credentials.set_store(current_store) + + class BasicCredentialsTests(unittest.TestCase): def setUp(self): - access_token = "foo" - client_id = "some_client_id" - client_secret = "cOuDdkfjxxnv+" - refresh_token = "1/0/a.df219fjls0" + access_token = 'foo' + client_id = 'some_client_id' + client_secret = 'cOuDdkfjxxnv+' + refresh_token = '1/0/a.df219fjls0' token_expiry = datetime.datetime.utcnow() - token_uri = "https://www.google.com/accounts/o8/oauth2/token" - user_agent = "refresh_checker/1.0" + user_agent = 'refresh_checker/1.0' self.credentials = OAuth2Credentials( - access_token, client_id, client_secret, - refresh_token, token_expiry, token_uri, - user_agent) + access_token, client_id, client_secret, + refresh_token, token_expiry, GOOGLE_TOKEN_URI, + user_agent, revoke_uri=GOOGLE_REVOKE_URI) def test_token_refresh_success(self): for status_code in REFRESH_STATUS_CODES: @@ -112,7 +148,7 @@ class BasicCredentialsTests(unittest.TestCase): ({'status': '200'}, 'echo_request_headers'), ]) http = self.credentials.authorize(http) - resp, content = http.request("http://example.com") + resp, content = http.request('http://example.com') self.assertEqual('Bearer 1/3w', content['Authorization']) self.assertFalse(self.credentials.access_token_expired) @@ -124,18 +160,28 @@ class BasicCredentialsTests(unittest.TestCase): ]) http = self.credentials.authorize(http) try: - http.request("http://example.com") - self.fail("should raise AccessTokenRefreshError exception") + http.request('http://example.com') + self.fail('should raise AccessTokenRefreshError exception') except AccessTokenRefreshError: pass self.assertTrue(self.credentials.access_token_expired) + def test_token_revoke_success(self): + _token_revoke_test_helper( + self, '200', revoke_raise=False, + valid_bool_value=True, token_attr='refresh_token') + + def test_token_revoke_failure(self): + _token_revoke_test_helper( + self, '400', revoke_raise=True, + valid_bool_value=False, token_attr='refresh_token') + def test_non_401_error_response(self): http = HttpMockSequence([ ({'status': '400'}, ''), ]) http = self.credentials.authorize(http) - resp, content = http.request("http://example.com") + resp, content = http.request('http://example.com') self.assertEqual(400, resp.status) def test_to_from_json(self): @@ -153,17 +199,16 @@ class BasicCredentialsTests(unittest.TestCase): client_secret = u'cOuDdkfjxxnv+' refresh_token = u'1/0/a.df219fjls0' token_expiry = unicode(datetime.datetime.utcnow()) - token_uri = u'https://www.google.com/accounts/o8/oauth2/token' + token_uri = unicode(GOOGLE_TOKEN_URI) + revoke_uri = unicode(GOOGLE_REVOKE_URI) user_agent = u'refresh_checker/1.0' credentials = OAuth2Credentials(access_token, client_id, client_secret, refresh_token, token_expiry, token_uri, - user_agent) + user_agent, revoke_uri=revoke_uri) http = HttpMock(headers={'status': '200'}) http = credentials.authorize(http) - http.request(u'http://example.com', method=u'GET', headers={ - u'foo': u'bar' - }) + http.request(u'http://example.com', method=u'GET', headers={u'foo': u'bar'}) for k, v in http.headers.iteritems(): self.assertEqual(str, type(k)) self.assertEqual(str, type(v)) @@ -180,9 +225,10 @@ class BasicCredentialsTests(unittest.TestCase): class AccessTokenCredentialsTests(unittest.TestCase): def setUp(self): - access_token = "foo" - user_agent = "refresh_checker/1.0" - self.credentials = AccessTokenCredentials(access_token, user_agent) + access_token = 'foo' + user_agent = 'refresh_checker/1.0' + self.credentials = AccessTokenCredentials(access_token, user_agent, + revoke_uri=GOOGLE_REVOKE_URI) def test_token_refresh_success(self): for status_code in REFRESH_STATUS_CODES: @@ -191,12 +237,22 @@ class AccessTokenCredentialsTests(unittest.TestCase): ]) http = self.credentials.authorize(http) try: - resp, content = http.request("http://example.com") - self.fail("should throw exception if token expires") + resp, content = http.request('http://example.com') + self.fail('should throw exception if token expires') except AccessTokenCredentialsError: pass except Exception: - self.fail("should only throw AccessTokenCredentialsError") + self.fail('should only throw AccessTokenCredentialsError') + + def test_token_revoke_success(self): + _token_revoke_test_helper( + self, '200', revoke_raise=False, + valid_bool_value=True, token_attr='access_token') + + def test_token_revoke_failure(self): + _token_revoke_test_helper( + self, '400', revoke_raise=True, + valid_bool_value=False, token_attr='access_token') def test_non_401_error_response(self): http = HttpMockSequence([ @@ -216,8 +272,8 @@ class AccessTokenCredentialsTests(unittest.TestCase): class TestAssertionCredentials(unittest.TestCase): - assertion_text = "This is the assertion" - assertion_type = "http://www.google.com/assertionType" + assertion_text = 'This is the assertion' + assertion_type = 'http://www.google.com/assertionType' class AssertionCredentialsTestImpl(AssertionCredentials): @@ -225,7 +281,7 @@ class TestAssertionCredentials(unittest.TestCase): return TestAssertionCredentials.assertion_text def setUp(self): - user_agent = "fun/2.0" + user_agent = 'fun/2.0' self.credentials = self.AssertionCredentialsTestImpl(self.assertion_type, user_agent=user_agent) @@ -240,11 +296,34 @@ class TestAssertionCredentials(unittest.TestCase): ({'status': '200'}, 'echo_request_headers'), ]) http = self.credentials.authorize(http) - resp, content = http.request("http://example.com") + resp, content = http.request('http://example.com') self.assertEqual('Bearer 1/3w', content['Authorization']) + def test_token_revoke_success(self): + _token_revoke_test_helper( + self, '200', revoke_raise=False, + valid_bool_value=True, token_attr='access_token') -class ExtractIdTokenText(unittest.TestCase): + def test_token_revoke_failure(self): + _token_revoke_test_helper( + self, '400', revoke_raise=True, + valid_bool_value=False, token_attr='access_token') + + +class UpdateQueryParamsTest(unittest.TestCase): + def test_update_query_params_no_params(self): + uri = 'http://www.google.com' + updated = _update_query_params(uri, {'a': 'b'}) + self.assertEqual(updated, uri + '?a=b') + + def test_update_query_params_existing_params(self): + uri = 'http://www.google.com?x=y' + updated = _update_query_params(uri, {'a': 'b', 'c': 'd&'}) + hardcoded_update = uri + '&a=b&c=d%26' + assertUrisEqual(self, updated, hardcoded_update) + + +class ExtractIdTokenTest(unittest.TestCase): """Tests _extract_id_token().""" def test_extract_success(self): @@ -272,13 +351,14 @@ class OAuth2WebServerFlowTest(unittest.TestCase): scope='foo', redirect_uri=OOB_CALLBACK_URN, user_agent='unittest-sample/1.0', + revoke_uri='dummy_revoke_uri', ) def test_construct_authorize_url(self): authorize_url = self.flow.step1_get_authorize_url() parsed = urlparse.urlparse(authorize_url) - q = parse_qs(parsed[4]) + q = urlparse.parse_qs(parsed[4]) self.assertEqual('client_id+1', q['client_id'][0]) self.assertEqual('code', q['response_type'][0]) self.assertEqual('foo', q['scope'][0]) @@ -299,7 +379,7 @@ class OAuth2WebServerFlowTest(unittest.TestCase): authorize_url = flow.step1_get_authorize_url() parsed = urlparse.urlparse(authorize_url) - q = parse_qs(parsed[4]) + q = urlparse.parse_qs(parsed[4]) self.assertEqual('client_id+1', q['client_id'][0]) self.assertEqual('token', q['response_type'][0]) self.assertEqual('foo', q['scope'][0]) @@ -313,23 +393,23 @@ class OAuth2WebServerFlowTest(unittest.TestCase): try: credentials = self.flow.step2_exchange('some random code', http=http) - self.fail("should raise exception if exchange doesn't get 200") + self.fail('should raise exception if exchange doesn\'t get 200') except FlowExchangeError: pass def test_urlencoded_exchange_failure(self): http = HttpMockSequence([ - ({'status': '400'}, "error=invalid_request"), + ({'status': '400'}, 'error=invalid_request'), ]) try: credentials = self.flow.step2_exchange('some random code', http=http) - self.fail("should raise exception if exchange doesn't get 200") + self.fail('should raise exception if exchange doesn\'t get 200') except FlowExchangeError, e: self.assertEquals('invalid_request', str(e)) def test_exchange_failure_with_json_error(self): - # Some providers have "error" attribute as a JSON object + # Some providers have 'error' attribute as a JSON object # in place of regular string. # This test makes sure no strange object-to-string coversion # exceptions are being raised instead of FlowExchangeError. @@ -342,7 +422,7 @@ class OAuth2WebServerFlowTest(unittest.TestCase): try: credentials = self.flow.step2_exchange('some random code', http=http) - self.fail("should raise exception if exchange doesn't get 200") + self.fail('should raise exception if exchange doesn\'t get 200') except FlowExchangeError, e: pass @@ -358,10 +438,11 @@ class OAuth2WebServerFlowTest(unittest.TestCase): self.assertEqual('SlAV32hkKG', credentials.access_token) self.assertNotEqual(None, credentials.token_expiry) self.assertEqual('8xLOxBtZp8', credentials.refresh_token) + self.assertEqual('dummy_revoke_uri', credentials.revoke_uri) def test_urlencoded_exchange_success(self): http = HttpMockSequence([ - ({'status': '200'}, "access_token=SlAV32hkKG&expires_in=3600"), + ({'status': '200'}, 'access_token=SlAV32hkKG&expires_in=3600'), ]) credentials = self.flow.step2_exchange('some random code', http=http) @@ -370,9 +451,9 @@ class OAuth2WebServerFlowTest(unittest.TestCase): def test_urlencoded_expires_param(self): http = HttpMockSequence([ - # Note the "expires=3600" where you'd normally - # have if named "expires_in" - ({'status': '200'}, "access_token=SlAV32hkKG&expires=3600"), + # Note the 'expires=3600' where you'd normally + # have if named 'expires_in' + ({'status': '200'}, 'access_token=SlAV32hkKG&expires=3600'), ]) credentials = self.flow.step2_exchange('some random code', http=http) @@ -391,7 +472,7 @@ class OAuth2WebServerFlowTest(unittest.TestCase): http = HttpMockSequence([ # This might be redundant but just to make sure # urlencoded access_token gets parsed correctly - ({'status': '200'}, "access_token=SlAV32hkKG"), + ({'status': '200'}, 'access_token=SlAV32hkKG'), ]) credentials = self.flow.step2_exchange('some random code', http=http) @@ -456,15 +537,15 @@ class CredentialsFromCodeTests(unittest.TestCase): self.redirect_uri = 'postmessage' def test_exchange_code_for_token(self): + token = 'asdfghjkl' + payload =simplejson.dumps({'access_token': token, 'expires_in': 3600}) http = HttpMockSequence([ - ({'status': '200'}, - """{ "access_token":"asdfghjkl", - "expires_in":3600 }"""), + ({'status': '200'}, payload), ]) credentials = credentials_from_code(self.client_id, self.client_secret, self.scope, self.code, redirect_uri=self.redirect_uri, http=http) - self.assertEquals(credentials.access_token, 'asdfghjkl') + self.assertEquals(credentials.access_token, token) self.assertNotEqual(None, credentials.token_expiry) def test_exchange_code_for_token_fail(self): @@ -476,7 +557,7 @@ class CredentialsFromCodeTests(unittest.TestCase): credentials = credentials_from_code(self.client_id, self.client_secret, self.scope, self.code, redirect_uri=self.redirect_uri, http=http) - self.fail("should raise exception if exchange doesn't get 200") + self.fail('should raise exception if exchange doesn\'t get 200') except FlowExchangeError: pass @@ -500,8 +581,8 @@ class CredentialsFromCodeTests(unittest.TestCase): load_and_cache('client_secrets.json', 'some_secrets', cache_mock) credentials = credentials_from_clientsecrets_and_code( - 'some_secrets', self.scope, - self.code, http=http, cache=cache_mock) + 'some_secrets', self.scope, + self.code, http=http, cache=cache_mock) self.assertEquals(credentials.access_token, 'asdfghjkl') def test_exchange_code_and_file_for_token_fail(self): @@ -513,7 +594,7 @@ class CredentialsFromCodeTests(unittest.TestCase): credentials = credentials_from_clientsecrets_and_code( datafile('client_secrets.json'), self.scope, self.code, http=http) - self.fail("should raise exception if exchange doesn't get 200") + self.fail('should raise exception if exchange doesn\'t get 200') except FlowExchangeError: pass diff --git a/tests/test_oauth2client_appengine.py b/tests/test_oauth2client_appengine.py index 870b5a8..2d95f08 100644 --- a/tests/test_oauth2client_appengine.py +++ b/tests/test_oauth2client_appengine.py @@ -52,6 +52,7 @@ from google.appengine.ext import ndb from google.appengine.ext import testbed from google.appengine.runtime import apiproxy_errors from oauth2client import appengine +from oauth2client import GOOGLE_TOKEN_URI from oauth2client.anyjson import simplejson from oauth2client.clientsecrets import _loadfile from oauth2client.clientsecrets import InvalidClientSecretsError @@ -156,12 +157,12 @@ class TestAppAssertionCredentials(unittest.TestCase): def test_raise_correct_type_of_exception(self): app_identity_stub = self.ErroringAppIdentityStubImpl() apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap() - apiproxy_stub_map.apiproxy.RegisterStub("app_identity_service", + apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service', app_identity_stub) apiproxy_stub_map.apiproxy.RegisterStub( 'memcache', memcache_stub.MemcacheServiceStub()) - scope = "http://www.googleapis.com/scope" + scope = 'http://www.googleapis.com/scope' try: credentials = AppAssertionCredentials(scope) http = httplib2.Http() @@ -271,16 +272,15 @@ class StorageByKeyNameTest(unittest.TestCase): self.testbed.init_memcache_stub() self.testbed.init_user_stub() - access_token = "foo" - client_id = "some_client_id" - client_secret = "cOuDdkfjxxnv+" - refresh_token = "1/0/a.df219fjls0" + access_token = 'foo' + client_id = 'some_client_id' + client_secret = 'cOuDdkfjxxnv+' + refresh_token = '1/0/a.df219fjls0' token_expiry = datetime.datetime.utcnow() - token_uri = "https://www.google.com/accounts/o8/oauth2/token" - user_agent = "refresh_checker/1.0" + user_agent = 'refresh_checker/1.0' self.credentials = OAuth2Credentials( access_token, client_id, client_secret, - refresh_token, token_expiry, token_uri, + refresh_token, token_expiry, GOOGLE_TOKEN_URI, user_agent) def tearDown(self): @@ -489,7 +489,7 @@ class DecoratorTests(unittest.TestCase): self.assertEqual(False, self.decorator.has_credentials()) m = mox.Mox() - m.StubOutWithMock(appengine, "_parse_state_value") + m.StubOutWithMock(appengine, '_parse_state_value') appengine._parse_state_value('foo_path:xsrfkey123', mox.IgnoreArg()).AndReturn('foo_path') m.ReplayAll() @@ -531,7 +531,7 @@ class DecoratorTests(unittest.TestCase): self.assertTrue(response.status.startswith('302')) m = mox.Mox() - m.StubOutWithMock(appengine, "_parse_state_value") + m.StubOutWithMock(appengine, '_parse_state_value') appengine._parse_state_value('foo_path:xsrfkey123', mox.IgnoreArg()).AndReturn('foo_path') m.ReplayAll() @@ -573,7 +573,7 @@ class DecoratorTests(unittest.TestCase): self.assertEqual('code', q['response_type'][0]) m = mox.Mox() - m.StubOutWithMock(appengine, "_parse_state_value") + m.StubOutWithMock(appengine, '_parse_state_value') appengine._parse_state_value('bar_path:xsrfkey456', mox.IgnoreArg()).AndReturn('bar_path') m.ReplayAll() @@ -616,7 +616,8 @@ class DecoratorTests(unittest.TestCase): user_agent='foo_user_agent', scope=['foo_scope', 'bar_scope'], access_type='offline', - approval_prompt='force') + approval_prompt='force', + revoke_uri='dummy_revoke_uri') request_handler = MockRequestHandler() decorator._create_flow(request_handler) @@ -625,6 +626,7 @@ class DecoratorTests(unittest.TestCase): self.assertEqual('offline', decorator.flow.params['access_type']) self.assertEqual('force', decorator.flow.params['approval_prompt']) self.assertEqual('foo_user_agent', decorator.flow.user_agent) + self.assertEqual('dummy_revoke_uri', decorator.flow.revoke_uri) self.assertEqual(None, decorator.flow.params.get('user_agent', None)) def test_decorator_from_client_secrets(self): @@ -639,6 +641,12 @@ class DecoratorTests(unittest.TestCase): http = self.decorator.http() self.assertEquals('foo_access_token', http.request.credentials.access_token) + # revoke_uri is not required + self.assertEqual(self.decorator._revoke_uri, + 'https://accounts.google.com/o/oauth2/revoke') + self.assertEqual(self.decorator._revoke_uri, + self.decorator.credentials.revoke_uri) + def test_decorator_from_cached_client_secrets(self): cache_mock = CacheMock() load_and_cache('client_secrets.json', 'secret', cache_mock) diff --git a/tests/test_oauth2client_file.py b/tests/test_oauth2client_file.py index 07e7608..c954d5e 100644 --- a/tests/test_oauth2client_file.py +++ b/tests/test_oauth2client_file.py @@ -32,6 +32,7 @@ import tempfile import unittest from apiclient.http import HttpMockSequence +from oauth2client import GOOGLE_TOKEN_URI from oauth2client import file from oauth2client import locked_file from oauth2client import multistore_file diff --git a/tests/test_oauth2client_keyring.py b/tests/test_oauth2client_keyring.py index 6fb0b9e..e5b9971 100644 --- a/tests/test_oauth2client_keyring.py +++ b/tests/test_oauth2client_keyring.py @@ -25,6 +25,7 @@ import keyring import unittest import mox +from oauth2client import GOOGLE_TOKEN_URI from oauth2client.client import OAuth2Credentials from oauth2client.keyring_storage import Storage @@ -65,12 +66,11 @@ class OAuth2ClientKeyringTests(unittest.TestCase): client_secret = 'cOuDdkfjxxnv+' refresh_token = '1/0/a.df219fjls0' token_expiry = datetime.datetime.utcnow() - token_uri = 'https://www.google.com/accounts/o8/oauth2/token' user_agent = 'refresh_checker/1.0' credentials = OAuth2Credentials( access_token, client_id, client_secret, - refresh_token, token_expiry, token_uri, + refresh_token, token_expiry, GOOGLE_TOKEN_URI, user_agent) m = mox.Mox()