Upgrade to oauth2client 2.0.1. Remove support for seperate pem keys.

This commit is contained in:
Jay Lee
2016-03-16 12:53:05 -04:00
parent 1d30eb7d91
commit d8a78d96ae
39 changed files with 7907 additions and 846 deletions

View File

@ -30,6 +30,7 @@ import tempfile
import time
import shutil
import six
from six.moves import http_client
from six.moves import urllib
import httplib2
@ -73,7 +74,7 @@ ID_TOKEN_VERIFICATON_CERTS = ID_TOKEN_VERIFICATION_CERTS
OOB_CALLBACK_URN = 'urn:ietf:wg:oauth:2.0:oob'
# Google Data client libraries may need to set this to [401, 403].
REFRESH_STATUS_CODES = [401]
REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,)
# The value representing user credentials.
AUTHORIZED_USER = 'authorized_user'
@ -101,6 +102,8 @@ ADC_HELP_MSG = (
'https://developers.google.com/accounts/docs/'
'application-default-credentials for more information.')
_WELL_KNOWN_CREDENTIALS_FILE = 'application_default_credentials.json'
# The access token along with the seconds in which it expires.
AccessTokenInfo = collections.namedtuple(
'AccessTokenInfo', ['access_token', 'expires_in'])
@ -115,6 +118,10 @@ _GCE_METADATA_HOST = '169.254.169.254'
_METADATA_FLAVOR_HEADER = 'Metadata-Flavor'
_DESIRED_METADATA_FLAVOR = 'Google'
# Expose utcnow() at module level to allow for
# easier testing (by replacing with a stub).
_UTCNOW = datetime.datetime.utcnow
class SETTINGS(object):
"""Settings namespace for globally defined values."""
@ -133,6 +140,13 @@ class AccessTokenRefreshError(Error):
"""Error trying to refresh an expired access token."""
class HttpAccessTokenRefreshError(AccessTokenRefreshError):
"""Error (with HTTP status) trying to refresh an expired access token."""
def __init__(self, *args, **kwargs):
super(HttpAccessTokenRefreshError, self).__init__(*args)
self.status = kwargs.get('status')
class TokenRevokeError(Error):
"""Error trying to revoke a token."""
@ -185,6 +199,13 @@ class MemoryCache(object):
self.cache.pop(key, None)
def _parse_expiry(expiry):
if expiry and isinstance(expiry, datetime.datetime):
return expiry.strftime(EXPIRY_FORMAT)
else:
return None
class Credentials(object):
"""Base class for all Credentials objects.
@ -195,7 +216,7 @@ class Credentials(object):
JSON string as input and returns an instantiated Credentials object.
"""
NON_SERIALIZED_MEMBERS = ['store']
NON_SERIALIZED_MEMBERS = frozenset(['store'])
def authorize(self, http):
"""Take an httplib2.Http instance (or equivalent) and authorizes it.
@ -236,34 +257,37 @@ class Credentials(object):
"""
_abstract()
def _to_json(self, strip):
def _to_json(self, strip, to_serialize=None):
"""Utility function that creates JSON repr. of a Credentials object.
Args:
strip: array, An array of names of members to not include in the
strip: array, An array of names of members to exclude from the
JSON.
to_serialize: dict, (Optional) The properties for this object
that will be serialized. This allows callers to modify
before serializing.
Returns:
string, a JSON representation of this instance, suitable to pass to
from_json().
"""
t = type(self)
d = copy.copy(self.__dict__)
curr_type = self.__class__
if to_serialize is None:
to_serialize = copy.copy(self.__dict__)
for member in strip:
if member in d:
del d[member]
if (d.get('token_expiry') and
isinstance(d['token_expiry'], datetime.datetime)):
d['token_expiry'] = d['token_expiry'].strftime(EXPIRY_FORMAT)
# Add in information we will need later to reconsistitue this instance.
d['_class'] = t.__name__
d['_module'] = t.__module__
for key, val in d.items():
if member in to_serialize:
del to_serialize[member]
to_serialize['token_expiry'] = _parse_expiry(
to_serialize.get('token_expiry'))
# Add in information we will need later to reconstitute this instance.
to_serialize['_class'] = curr_type.__name__
to_serialize['_module'] = curr_type.__module__
for key, val in to_serialize.items():
if isinstance(val, bytes):
d[key] = val.decode('utf-8')
to_serialize[key] = val.decode('utf-8')
if isinstance(val, set):
d[key] = list(val)
return json.dumps(d)
to_serialize[key] = list(val)
return json.dumps(to_serialize)
def to_json(self):
"""Creating a JSON representation of an instance of Credentials.
@ -272,23 +296,23 @@ class Credentials(object):
string, a JSON representation of this instance, suitable to pass to
from_json().
"""
return self._to_json(Credentials.NON_SERIALIZED_MEMBERS)
return self._to_json(self.NON_SERIALIZED_MEMBERS)
@classmethod
def new_from_json(cls, s):
def new_from_json(cls, json_data):
"""Utility class method to instantiate a Credentials subclass from JSON.
Expects the JSON string to have been produced by to_json().
Args:
s: string or bytes, JSON from to_json().
json_data: string or bytes, JSON from to_json().
Returns:
An instance of the subclass of Credentials that was serialized with
to_json().
"""
json_string_as_unicode = _from_bytes(s)
data = json.loads(json_string_as_unicode)
json_data_as_unicode = _from_bytes(json_data)
data = json.loads(json_data_as_unicode)
# Find and call the right classmethod from_json() to restore
# the object.
module_name = data['_module']
@ -303,8 +327,7 @@ class Credentials(object):
module_obj = __import__(module_name,
fromlist=module_name.split('.')[:-1])
kls = getattr(module_obj, data['_class'])
from_json = getattr(kls, 'from_json')
return from_json(json_string_as_unicode)
return kls.from_json(json_data_as_unicode)
@classmethod
def from_json(cls, unused_data):
@ -333,21 +356,31 @@ class Storage(object):
such that multiple processes and threads can operate on a single
store.
"""
def __init__(self, lock=None):
"""Create a Storage instance.
Args:
lock: An optional threading.Lock-like object. Must implement at
least acquire() and release(). Does not need to be re-entrant.
"""
self._lock = lock
def acquire_lock(self):
"""Acquires any lock necessary to access this Storage.
This lock is not reentrant.
"""
pass
if self._lock is not None:
self._lock.acquire()
def release_lock(self):
"""Release the Storage lock.
Trying to release a lock that isn't held will result in a
RuntimeError.
RuntimeError in the case of a threading.Lock or multiprocessing.Lock.
"""
pass
if self._lock is not None:
self._lock.release()
def locked_get(self):
"""Retrieve credential.
@ -676,23 +709,19 @@ class OAuth2Credentials(Credentials):
self._retrieve_scopes(http.request)
return self.scopes
def to_json(self):
return self._to_json(Credentials.NON_SERIALIZED_MEMBERS)
@classmethod
def from_json(cls, s):
def from_json(cls, json_data):
"""Instantiate a Credentials object from a JSON description of it.
The JSON should have been produced by calling .to_json() on the object.
Args:
data: dict, A deserialized JSON object.
json_data: string or bytes, JSON to deserialize.
Returns:
An instance of a Credentials subclass.
"""
s = _from_bytes(s)
data = json.loads(s)
data = json.loads(_from_bytes(json_data))
if (data.get('token_expiry') and
not isinstance(data['token_expiry'], datetime.datetime)):
try:
@ -728,7 +757,7 @@ class OAuth2Credentials(Credentials):
if not self.token_expiry:
return False
now = datetime.datetime.utcnow()
now = _UTCNOW()
if now >= self.token_expiry:
logger.info('access_token is expired. Now: %s, token_expiry: %s',
now, self.token_expiry)
@ -771,7 +800,7 @@ class OAuth2Credentials(Credentials):
valid; we just don't know anything about it.
"""
if self.token_expiry:
now = datetime.datetime.utcnow()
now = _UTCNOW()
if self.token_expiry > now:
time_delta = self.token_expiry - now
# TODO(orestica): return time_delta.total_seconds()
@ -829,7 +858,7 @@ class OAuth2Credentials(Credentials):
refresh request.
Raises:
AccessTokenRefreshError: When the refresh fails.
HttpAccessTokenRefreshError: When the refresh fails.
"""
if not self.store:
self._do_refresh_request(http_request)
@ -857,7 +886,7 @@ class OAuth2Credentials(Credentials):
refresh request.
Raises:
AccessTokenRefreshError: When the refresh fails.
HttpAccessTokenRefreshError: When the refresh fails.
"""
body = self._generate_refresh_request_body()
headers = self._generate_refresh_request_headers()
@ -866,16 +895,20 @@ class OAuth2Credentials(Credentials):
resp, content = http_request(
self.token_uri, method='POST', body=body, headers=headers)
content = _from_bytes(content)
if resp.status == 200:
if resp.status == http_client.OK:
d = json.loads(content)
self.token_response = d
self.access_token = d['access_token']
self.refresh_token = d.get('refresh_token', self.refresh_token)
if 'expires_in' in d:
self.token_expiry = datetime.timedelta(
seconds=int(d['expires_in'])) + datetime.datetime.utcnow()
delta = datetime.timedelta(seconds=int(d['expires_in']))
self.token_expiry = delta + _UTCNOW()
else:
self.token_expiry = None
if 'id_token' in d:
self.id_token = _extract_id_token(d['id_token'])
else:
self.id_token = None
# On temporary refresh errors, the user does not actually have to
# re-authorize, so we unflag here.
self.invalid = False
@ -897,7 +930,7 @@ class OAuth2Credentials(Credentials):
self.store.locked_put(self)
except (TypeError, ValueError):
pass
raise AccessTokenRefreshError(error_msg)
raise HttpAccessTokenRefreshError(error_msg, status=resp.status)
def _revoke(self, http_request):
"""Revokes this credential and deletes the stored copy (if it exists).
@ -927,7 +960,7 @@ class OAuth2Credentials(Credentials):
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:
if resp.status == http_client.OK:
self.invalid = True
else:
error_msg = 'Invalid response %s.' % resp.status
@ -972,7 +1005,7 @@ class OAuth2Credentials(Credentials):
query_params)
resp, content = http_request(token_info_uri)
content = _from_bytes(content)
if resp.status == 200:
if resp.status == http_client.OK:
d = json.loads(content)
self.scopes = set(util.string_to_scopes(d.get('scope', '')))
else:
@ -1036,8 +1069,8 @@ class AccessTokenCredentials(OAuth2Credentials):
revoke_uri=revoke_uri)
@classmethod
def from_json(cls, s):
data = json.loads(_from_bytes(s))
def from_json(cls, json_data):
data = json.loads(_from_bytes(json_data))
retval = AccessTokenCredentials(
data['access_token'],
data['user_agent'])
@ -1078,7 +1111,7 @@ def _detect_gce_environment():
headers = {_METADATA_FLAVOR_HEADER: _DESIRED_METADATA_FLAVOR}
connection.request('GET', '/', headers=headers)
response = connection.getresponse()
if response.status == 200:
if response.status == http_client.OK:
return (response.getheader(_METADATA_FLAVOR_HEADER) ==
_DESIRED_METADATA_FLAVOR)
except socket.error: # socket.timeout or socket.error(64, 'Host is down')
@ -1098,7 +1131,10 @@ def _in_gae_environment():
return SETTINGS.env_name in ('GAE_PRODUCTION', 'GAE_LOCAL')
try:
import google.appengine
import google.appengine # noqa: unused import
except ImportError:
pass
else:
server_software = os.environ.get(_SERVER_SOFTWARE, '')
if server_software.startswith('Google App Engine/'):
SETTINGS.env_name = 'GAE_PRODUCTION'
@ -1106,8 +1142,6 @@ def _in_gae_environment():
elif server_software.startswith('Development/'):
SETTINGS.env_name = 'GAE_LOCAL'
return True
except ImportError:
pass
return False
@ -1152,6 +1186,11 @@ class GoogleCredentials(OAuth2Credentials):
print(response)
"""
NON_SERIALIZED_MEMBERS = (
frozenset(['_private_key']) |
OAuth2Credentials.NON_SERIALIZED_MEMBERS)
"""Members that aren't serialized when object is converted to JSON."""
def __init__(self, access_token, client_id, client_secret, refresh_token,
token_expiry, token_uri, user_agent,
revoke_uri=GOOGLE_REVOKE_URI):
@ -1194,6 +1233,32 @@ class GoogleCredentials(OAuth2Credentials):
"""
return self
@classmethod
def from_json(cls, json_data):
# TODO(issue 388): eliminate the circularity that is the reason for
# this non-top-level import.
from oauth2client.service_account import ServiceAccountCredentials
data = json.loads(_from_bytes(json_data))
# We handle service_account.ServiceAccountCredentials since it is a
# possible return type of GoogleCredentials.get_application_default()
if (data['_module'] == 'oauth2client.service_account' and
data['_class'] == 'ServiceAccountCredentials'):
return ServiceAccountCredentials.from_json(data)
token_expiry = _parse_expiry(data.get('token_expiry'))
google_credentials = cls(
data['access_token'],
data['client_id'],
data['client_secret'],
data['refresh_token'],
token_expiry,
data['token_uri'],
data['user_agent'],
revoke_uri=data.get('revoke_uri', None))
google_credentials.invalid = data['invalid']
return google_credentials
@property
def serialization_data(self):
"""Get the fields and values identifying the current credentials."""
@ -1407,9 +1472,6 @@ def _get_well_known_file():
"""Get the well known file produced by command 'gcloud auth login'."""
# TODO(orestica): Revisit this method once gcloud provides a better way
# of pinpointing the exact location of the file.
WELL_KNOWN_CREDENTIALS_FILE = 'application_default_credentials.json'
default_config_dir = os.getenv(_CLOUDSDK_CONFIG_ENV_VAR)
if default_config_dir is None:
if os.name == 'nt':
@ -1427,14 +1489,11 @@ def _get_well_known_file():
'.config',
_CLOUDSDK_CONFIG_DIRECTORY)
return os.path.join(default_config_dir, WELL_KNOWN_CREDENTIALS_FILE)
return os.path.join(default_config_dir, _WELL_KNOWN_CREDENTIALS_FILE)
def _get_application_default_credential_from_file(filename):
"""Build the Application Default Credentials from file."""
from oauth2client import service_account
# read the credentials from the file
with open(filename) as file_obj:
client_credentials = json.load(file_obj)
@ -1465,12 +1524,9 @@ def _get_application_default_credential_from_file(filename):
token_uri=GOOGLE_TOKEN_URI,
user_agent='Python client library')
else: # client_credentials['type'] == SERVICE_ACCOUNT
return service_account._ServiceAccountCredentials(
service_account_id=client_credentials['client_id'],
service_account_email=client_credentials['client_email'],
private_key_id=client_credentials['private_key_id'],
private_key_pkcs8_text=client_credentials['private_key'],
scopes=[])
from oauth2client.service_account import ServiceAccountCredentials
return ServiceAccountCredentials.from_json_keyfile_dict(
client_credentials)
def _raise_exception_for_missing_fields(missing_fields):
@ -1487,15 +1543,15 @@ def _raise_exception_for_reading_json(credential_file,
def _get_application_default_credential_GAE():
from oauth2client.appengine import AppAssertionCredentials
from oauth2client.contrib.appengine import AppAssertionCredentials
return AppAssertionCredentials([])
def _get_application_default_credential_GCE():
from oauth2client.gce import AppAssertionCredentials
from oauth2client.contrib.gce import AppAssertionCredentials
return AppAssertionCredentials([])
return AppAssertionCredentials()
class AssertionCredentials(GoogleCredentials):
@ -1561,6 +1617,18 @@ class AssertionCredentials(GoogleCredentials):
"""
self._do_revoke(http_request, self.access_token)
def sign_blob(self, blob):
"""Cryptographically sign a blob (of bytes).
Args:
blob: bytes, Message to be signed.
Returns:
tuple, A pair of the private key ID used to sign the blob and
the signed contents.
"""
raise NotImplementedError('This method is abstract.')
def _RequireCryptoOrDie():
"""Ensure we have a crypto library, or throw CryptoUnavailableError.
@ -1573,102 +1641,6 @@ def _RequireCryptoOrDie():
raise CryptoUnavailableError('No crypto library available')
class SignedJwtAssertionCredentials(AssertionCredentials):
"""Credentials object used for OAuth 2.0 Signed JWT assertion grants.
This credential does not require a flow to instantiate because it
represents a two legged flow, and therefore has all of the required
information to generate and refresh its own access tokens.
SignedJwtAssertionCredentials requires either PyOpenSSL, or PyCrypto
2.6 or later. For App Engine you may also consider using
AppAssertionCredentials.
"""
MAX_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
@util.positional(4)
def __init__(self,
service_account_name,
private_key,
scope,
private_key_password='notasecret',
user_agent=None,
token_uri=GOOGLE_TOKEN_URI,
revoke_uri=GOOGLE_REVOKE_URI,
**kwargs):
"""Constructor for SignedJwtAssertionCredentials.
Args:
service_account_name: string, id for account, usually an email
address.
private_key: string, private key in PKCS12 or PEM format.
scope: string or iterable of strings, scope(s) of the credentials
being requested.
private_key_password: string, password for private_key, unused if
private_key is in PEM format.
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 sub=joe@xample.org.
Raises:
CryptoUnavailableError if no crypto library is available.
"""
_RequireCryptoOrDie()
super(SignedJwtAssertionCredentials, self).__init__(
None,
user_agent=user_agent,
token_uri=token_uri,
revoke_uri=revoke_uri,
)
self.scope = util.scopes_to_string(scope)
# Keep base64 encoded so it can be stored in JSON.
self.private_key = base64.b64encode(private_key)
self.private_key = _to_bytes(self.private_key, encoding='utf-8')
self.private_key_password = private_key_password
self.service_account_name = service_account_name
self.kwargs = kwargs
@classmethod
def from_json(cls, s):
data = json.loads(_from_bytes(s))
retval = SignedJwtAssertionCredentials(
data['service_account_name'],
base64.b64decode(data['private_key']),
data['scope'],
private_key_password=data['private_key_password'],
user_agent=data['user_agent'],
token_uri=data['token_uri'],
**data['kwargs']
)
retval.invalid = data['invalid']
retval.access_token = data['access_token']
return retval
def _generate_assertion(self):
"""Generate the assertion that will be used in the request."""
now = int(time.time())
payload = {
'aud': self.token_uri,
'scope': self.scope,
'iat': now,
'exp': now + SignedJwtAssertionCredentials.MAX_TOKEN_LIFETIME_SECS,
'iss': self.service_account_name
}
payload.update(self.kwargs)
logger.debug(str(payload))
private_key = base64.b64decode(self.private_key)
return crypt.make_signed_jwt(crypt.Signer.from_string(
private_key, self.private_key_password), payload)
# Only used in verify_id_token(), which is always calling to the same URI
# for the certs.
_cached_http = httplib2.Http(MemoryCache())
@ -1702,7 +1674,7 @@ def verify_id_token(id_token, audience, http=None,
http = _cached_http
resp, content = http.request(cert_uri)
if resp.status == 200:
if resp.status == http_client.OK:
certs = json.loads(_from_bytes(content))
return crypt.verify_signed_jwt_with_certs(id_token, certs, audience)
else:
@ -2047,7 +2019,7 @@ class OAuth2WebServerFlow(Flow):
resp, content = http.request(self.device_uri, method='POST', body=body,
headers=headers)
content = _from_bytes(content)
if resp.status == 200:
if resp.status == http_client.OK:
try:
flow_info = json.loads(content)
except ValueError as e:
@ -2130,7 +2102,7 @@ class OAuth2WebServerFlow(Flow):
resp, content = http.request(self.token_uri, method='POST', body=body,
headers=headers)
d = _parse_exchange_token_response(content)
if resp.status == 200 and 'access_token' in d:
if resp.status == http_client.OK and 'access_token' in d:
access_token = d['access_token']
refresh_token = d.get('refresh_token', None)
if not refresh_token:
@ -2139,9 +2111,8 @@ class OAuth2WebServerFlow(Flow):
"reauthenticating with approval_prompt='force'.")
token_expiry = None
if 'expires_in' in d:
token_expiry = (
datetime.datetime.utcnow() +
datetime.timedelta(seconds=int(d['expires_in'])))
delta = datetime.timedelta(seconds=int(d['expires_in']))
token_expiry = delta + _UTCNOW()
extracted_id_token = None
if 'id_token' in d: