Adding a .revoke() to Credentials. Closes issue 98.
Reviewed in https://codereview.appspot.com/7033052/
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 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 @@
@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 @@
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 @@
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 @@
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 @@
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 @@
"""
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 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 @@
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 @@
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 @@
"""
_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 @@
_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 @@
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 @@
@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 @@
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 @@
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 @@
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 @@
"""
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 @@
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 @@
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 @@
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 @@
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 @@
None,
None,
None,
- user_agent)
+ user_agent,
+ revoke_uri=revoke_uri)
@classmethod
@@ -715,7 +799,16 @@
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 @@
@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 @@
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 @@
"""
_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 @@
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 @@
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 @@
'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 @@
@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 @@
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 @@
"""
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
@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 @@
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 @@
"""
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 @@
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 @@
'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 @@
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 @@
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 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 @@
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 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 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 @@
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 @@
({'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 @@
])
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 @@
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 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 @@
])
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 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 @@
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 @@
({'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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 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 unittest
import mox
+from oauth2client import GOOGLE_TOKEN_URI
from oauth2client.client import OAuth2Credentials
from oauth2client.keyring_storage import Storage
@@ -65,12 +66,11 @@
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()