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()