From 4a894958f06cc2bff9e9ca8615a898490ee81958 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Tue, 24 Jan 2017 14:00:07 -0500 Subject: [PATCH] oauth2client 4.0 --- src/oauth2client/__init__.py | 2 +- src/oauth2client/_helpers.py | 236 ++++++++++++++++ src/oauth2client/_pkce.py | 65 +++++ src/oauth2client/client.py | 257 ++++++++++-------- src/oauth2client/clientsecrets.py | 1 - src/oauth2client/contrib/_metadata.py | 39 ++- src/oauth2client/contrib/appengine.py | 33 +-- src/oauth2client/contrib/devshell.py | 7 +- .../contrib/django_util/__init__.py | 36 ++- src/oauth2client/contrib/django_util/views.py | 19 +- src/oauth2client/contrib/flask_util.py | 7 +- src/oauth2client/contrib/gce.py | 32 +-- src/oauth2client/contrib/keyring_storage.py | 3 - src/oauth2client/contrib/xsrfutil.py | 9 +- src/oauth2client/file.py | 21 +- src/oauth2client/service_account.py | 18 +- src/oauth2client/tools.py | 27 +- src/oauth2client/transport.py | 74 +++-- 18 files changed, 615 insertions(+), 271 deletions(-) create mode 100644 src/oauth2client/_pkce.py diff --git a/src/oauth2client/__init__.py b/src/oauth2client/__init__.py index 28384bb0..ef19360a 100644 --- a/src/oauth2client/__init__.py +++ b/src/oauth2client/__init__.py @@ -14,7 +14,7 @@ """Client library for using OAuth2, especially with Google APIs.""" -__version__ = '3.0.0' +__version__ = '4.0.0' GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/v2/auth' GOOGLE_DEVICE_URI = 'https://accounts.google.com/o/oauth2/device/code' diff --git a/src/oauth2client/_helpers.py b/src/oauth2client/_helpers.py index cb959c5b..e9123971 100644 --- a/src/oauth2client/_helpers.py +++ b/src/oauth2client/_helpers.py @@ -11,12 +11,248 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + """Helper functions for commonly used utilities.""" import base64 +import functools +import inspect import json +import logging +import os +import warnings import six +from six.moves import urllib + + +logger = logging.getLogger(__name__) + +POSITIONAL_WARNING = 'WARNING' +POSITIONAL_EXCEPTION = 'EXCEPTION' +POSITIONAL_IGNORE = 'IGNORE' +POSITIONAL_SET = frozenset([POSITIONAL_WARNING, POSITIONAL_EXCEPTION, + POSITIONAL_IGNORE]) + +positional_parameters_enforcement = POSITIONAL_WARNING + +_SYM_LINK_MESSAGE = 'File: {0}: Is a symbolic link.' +_IS_DIR_MESSAGE = '{0}: Is a directory' +_MISSING_FILE_MESSAGE = 'Cannot access {0}: No such file or directory' + + +def positional(max_positional_args): + """A decorator to declare that only the first N arguments my be positional. + + This decorator makes it easy to support Python 3 style keyword-only + parameters. For example, in Python 3 it is possible to write:: + + def fn(pos1, *, kwonly1=None, kwonly1=None): + ... + + All named parameters after ``*`` must be a keyword:: + + fn(10, 'kw1', 'kw2') # Raises exception. + fn(10, kwonly1='kw1') # Ok. + + Example + ^^^^^^^ + + To define a function like above, do:: + + @positional(1) + def fn(pos1, kwonly1=None, kwonly2=None): + ... + + If no default value is provided to a keyword argument, it becomes a + required keyword argument:: + + @positional(0) + def fn(required_kw): + ... + + This must be called with the keyword parameter:: + + fn() # Raises exception. + fn(10) # Raises exception. + fn(required_kw=10) # Ok. + + When defining instance or class methods always remember to account for + ``self`` and ``cls``:: + + class MyClass(object): + + @positional(2) + def my_method(self, pos1, kwonly1=None): + ... + + @classmethod + @positional(2) + def my_method(cls, pos1, kwonly1=None): + ... + + The positional decorator behavior is controlled by + ``_helpers.positional_parameters_enforcement``, which may be set to + ``POSITIONAL_EXCEPTION``, ``POSITIONAL_WARNING`` or + ``POSITIONAL_IGNORE`` to raise an exception, log a warning, or do + nothing, respectively, if a declaration is violated. + + Args: + max_positional_arguments: Maximum number of positional arguments. All + parameters after the this index must be + keyword only. + + Returns: + A decorator that prevents using arguments after max_positional_args + from being used as positional parameters. + + Raises: + TypeError: if a key-word only argument is provided as a positional + parameter, but only if + _helpers.positional_parameters_enforcement is set to + POSITIONAL_EXCEPTION. + """ + + def positional_decorator(wrapped): + @functools.wraps(wrapped) + def positional_wrapper(*args, **kwargs): + if len(args) > max_positional_args: + plural_s = '' + if max_positional_args != 1: + plural_s = 's' + message = ('{function}() takes at most {args_max} positional ' + 'argument{plural} ({args_given} given)'.format( + function=wrapped.__name__, + args_max=max_positional_args, + args_given=len(args), + plural=plural_s)) + if positional_parameters_enforcement == POSITIONAL_EXCEPTION: + raise TypeError(message) + elif positional_parameters_enforcement == POSITIONAL_WARNING: + logger.warning(message) + return wrapped(*args, **kwargs) + return positional_wrapper + + if isinstance(max_positional_args, six.integer_types): + return positional_decorator + else: + args, _, _, defaults = inspect.getargspec(max_positional_args) + return positional(len(args) - len(defaults))(max_positional_args) + + +def scopes_to_string(scopes): + """Converts scope value to a string. + + If scopes is a string then it is simply passed through. If scopes is an + iterable then a string is returned that is all the individual scopes + concatenated with spaces. + + Args: + scopes: string or iterable of strings, the scopes. + + Returns: + The scopes formatted as a single string. + """ + if isinstance(scopes, six.string_types): + return scopes + else: + return ' '.join(scopes) + + +def string_to_scopes(scopes): + """Converts stringifed scope value to a list. + + If scopes is a list then it is simply passed through. If scopes is an + string then a list of each individual scope is returned. + + Args: + scopes: a string or iterable of strings, the scopes. + + Returns: + The scopes in a list. + """ + if not scopes: + return [] + elif isinstance(scopes, six.string_types): + return scopes.split(' ') + else: + return scopes + + +def parse_unique_urlencoded(content): + """Parses unique key-value parameters from urlencoded content. + + Args: + content: string, URL-encoded key-value pairs. + + Returns: + dict, The key-value pairs from ``content``. + + Raises: + ValueError: if one of the keys is repeated. + """ + urlencoded_params = urllib.parse.parse_qs(content) + params = {} + for key, value in six.iteritems(urlencoded_params): + if len(value) != 1: + msg = ('URL-encoded content contains a repeated value:' + '%s -> %s' % (key, ', '.join(value))) + raise ValueError(msg) + params[key] = value[0] + return params + + +def update_query_params(uri, params): + """Updates a URI with new query parameters. + + If a given key from ``params`` is repeated in the ``uri``, then + the URI will be considered invalid and an error will occur. + + If the URI is valid, then each value from ``params`` will + replace the corresponding value in the query parameters (if + it exists). + + 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 = urllib.parse.urlparse(uri) + query_params = parse_unique_urlencoded(parts.query) + query_params.update(params) + new_query = urllib.parse.urlencode(query_params) + new_parts = parts._replace(query=new_query) + return urllib.parse.urlunparse(new_parts) + + +def _add_query_parameter(url, name, value): + """Adds a query parameter to a url. + + Replaces the current value if it already exists in the URL. + + Args: + url: string, url to add the query parameter to. + name: string, query parameter name. + value: string, query parameter value. + + Returns: + Updated query parameter. Does not update the url if value is None. + """ + if value is None: + return url + else: + return update_query_params(url, {name: value}) + + +def validate_file(filename): + if os.path.islink(filename): + raise IOError(_SYM_LINK_MESSAGE.format(filename)) + elif os.path.isdir(filename): + raise IOError(_IS_DIR_MESSAGE.format(filename)) + elif not os.path.isfile(filename): + warnings.warn(_MISSING_FILE_MESSAGE.format(filename)) def _parse_pem_key(raw_key_input): diff --git a/src/oauth2client/_pkce.py b/src/oauth2client/_pkce.py new file mode 100644 index 00000000..8f22f579 --- /dev/null +++ b/src/oauth2client/_pkce.py @@ -0,0 +1,65 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Utility functions for implementing Proof Key for Code Exchange (PKCE) by OAuth +Public Clients + +See RFC7636. +""" + +import base64 +import hashlib +import os + + +def code_verifier(n_bytes=64): + """ + Generates a 'code_verifier' as described in section 4.1 of RFC 7636. + + This is a 'high-entropy cryptographic random string' that will be + impractical for an attacker to guess. + + Args: + n_bytes: integer between 31 and 96, inclusive. default: 64 + number of bytes of entropy to include in verifier. + + Returns: + Bytestring, representing urlsafe base64-encoded random data. + """ + verifier = base64.urlsafe_b64encode(os.urandom(n_bytes)) + # https://tools.ietf.org/html/rfc7636#section-4.1 + # minimum length of 43 characters and a maximum length of 128 characters. + if len(verifier) < 43: + raise ValueError("Verifier too short. n_bytes must be > 30.") + elif len(verifier) > 128: + raise ValueError("Verifier too long. n_bytes must be < 97.") + else: + return verifier + + +def code_challenge(verifier): + """ + Creates a 'code_challenge' as described in section 4.2 of RFC 7636 + by taking the sha256 hash of the verifier and then urlsafe + base64-encoding it. + + Args: + verifier: bytestring, representing a code_verifier as generated by + code_verifier(). + + Returns: + Bytestring, representing a urlsafe base64-encoded sha256 hash digest. + """ + return base64.urlsafe_b64encode(hashlib.sha256(verifier).digest()) diff --git a/src/oauth2client/client.py b/src/oauth2client/client.py index d535ff17..0a1aff9a 100644 --- a/src/oauth2client/client.py +++ b/src/oauth2client/client.py @@ -34,13 +34,11 @@ from six.moves import urllib import oauth2client from oauth2client import _helpers +from oauth2client import _pkce from oauth2client import clientsecrets from oauth2client import transport -from oauth2client import util -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - HAS_OPENSSL = False HAS_CRYPTO = False try: @@ -100,20 +98,20 @@ AccessTokenInfo = collections.namedtuple( DEFAULT_ENV_NAME = 'UNKNOWN' # If set to True _get_environment avoid GCE check (_detect_gce_environment) -NO_GCE_CHECK = os.environ.setdefault('NO_GCE_CHECK', 'False') +NO_GCE_CHECK = os.getenv('NO_GCE_CHECK', 'False') # Timeout in seconds to wait for the GCE metadata server when detecting the # GCE environment. try: - GCE_METADATA_TIMEOUT = int( - os.environ.setdefault('GCE_METADATA_TIMEOUT', '3')) + GCE_METADATA_TIMEOUT = int(os.getenv('GCE_METADATA_TIMEOUT', 3)) except ValueError: # pragma: NO COVER GCE_METADATA_TIMEOUT = 3 _SERVER_SOFTWARE = 'SERVER_SOFTWARE' -_GCE_METADATA_HOST = '169.254.169.254' -_METADATA_FLAVOR_HEADER = 'Metadata-Flavor' +_GCE_METADATA_URI = 'http://169.254.169.254' +_METADATA_FLAVOR_HEADER = 'metadata-flavor' # lowercase header _DESIRED_METADATA_FLAVOR = 'Google' +_GCE_HEADERS = {_METADATA_FLAVOR_HEADER: _DESIRED_METADATA_FLAVOR} # Expose utcnow() at module level to allow for # easier testing (by replacing with a stub). @@ -273,7 +271,7 @@ class Credentials(object): to_serialize[key] = val.decode('utf-8') if isinstance(val, set): to_serialize[key] = list(val) - return json.dumps(to_serialize, indent=4, sort_keys=True) + return json.dumps(to_serialize) def to_json(self): """Creating a JSON representation of an instance of Credentials. @@ -440,23 +438,6 @@ class Storage(object): self.release_lock() -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 = urllib.parse.urlparse(uri) - query_params = dict(urllib.parse.parse_qsl(parts.query)) - query_params.update(params) - new_parts = parts._replace(query=urllib.parse.urlencode(query_params)) - return urllib.parse.urlunparse(new_parts) - - class OAuth2Credentials(Credentials): """Credentials object for OAuth 2.0. @@ -466,7 +447,7 @@ class OAuth2Credentials(Credentials): OAuth2Credentials objects may be safely pickled and unpickled. """ - @util.positional(8) + @_helpers.positional(8) def __init__(self, access_token, client_id, client_secret, refresh_token, token_expiry, token_uri, user_agent, revoke_uri=None, id_token=None, token_response=None, scopes=None, @@ -513,7 +494,7 @@ class OAuth2Credentials(Credentials): self.revoke_uri = revoke_uri self.id_token = id_token self.token_response = token_response - self.scopes = set(util.string_to_scopes(scopes or [])) + self.scopes = set(_helpers.string_to_scopes(scopes or [])) self.token_info_uri = token_info_uri # True if the credentials have been revoked or expired and can't be @@ -557,7 +538,7 @@ class OAuth2Credentials(Credentials): http: httplib2.Http, an http object to be used to make the refresh request. """ - self._refresh(http.request) + self._refresh(http) def revoke(self, http): """Revokes a refresh_token and makes the credentials void. @@ -566,7 +547,7 @@ class OAuth2Credentials(Credentials): http: httplib2.Http, an http object to be used to make the revoke request. """ - self._revoke(http.request) + self._revoke(http) def apply(self, headers): """Add the authorization to the headers. @@ -592,7 +573,7 @@ class OAuth2Credentials(Credentials): not have scopes. In both cases, you can use refresh_scopes() to obtain the canonical set of scopes. """ - scopes = util.string_to_scopes(scopes) + scopes = _helpers.string_to_scopes(scopes) return set(scopes).issubset(self.scopes) def retrieve_scopes(self, http): @@ -607,7 +588,7 @@ class OAuth2Credentials(Credentials): Returns: A set of strings containing the canonical list of scopes. """ - self._retrieve_scopes(http.request) + self._retrieve_scopes(http) return self.scopes @classmethod @@ -746,7 +727,7 @@ class OAuth2Credentials(Credentials): return headers - def _refresh(self, http_request): + def _refresh(self, http): """Refreshes the access_token. This method first checks by reading the Storage object if available. @@ -754,15 +735,13 @@ class OAuth2Credentials(Credentials): refresh is completed. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - refresh request. + http: an object to be used to make HTTP requests. Raises: HttpAccessTokenRefreshError: When the refresh fails. """ if not self.store: - self._do_refresh_request(http_request) + self._do_refresh_request(http) else: self.store.acquire_lock() try: @@ -774,17 +753,15 @@ class OAuth2Credentials(Credentials): logger.info('Updated access_token read from Storage') self._updateFromCredential(new_cred) else: - self._do_refresh_request(http_request) + self._do_refresh_request(http) finally: self.store.release_lock() - def _do_refresh_request(self, http_request): + def _do_refresh_request(self, http): """Refresh the access_token using the refresh_token. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - refresh request. + http: an object to be used to make HTTP requests. Raises: HttpAccessTokenRefreshError: When the refresh fails. @@ -793,8 +770,9 @@ class OAuth2Credentials(Credentials): headers = self._generate_refresh_request_headers() logger.info('Refreshing access_token') - resp, content = http_request( - self.token_uri, method='POST', body=body, headers=headers) + resp, content = transport.request( + http, self.token_uri, method='POST', + body=body, headers=headers) content = _helpers._from_bytes(content) if resp.status == http_client.OK: d = json.loads(content) @@ -819,7 +797,7 @@ class OAuth2Credentials(Credentials): # An {'error':...} response body means the token is expired or # revoked, so we flag the credentials as such. logger.info('Failed to retrieve access token: %s', content) - error_msg = 'Invalid response {0}.'.format(resp['status']) + error_msg = 'Invalid response {0}.'.format(resp.status) try: d = json.loads(content) if 'error' in d: @@ -833,23 +811,19 @@ class OAuth2Credentials(Credentials): pass raise HttpAccessTokenRefreshError(error_msg, status=resp.status) - def _revoke(self, http_request): + def _revoke(self, http): """Revokes this credential and deletes the stored copy (if it exists). Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - revoke request. + http: an object to be used to make HTTP requests. """ - self._do_revoke(http_request, self.refresh_token or self.access_token) + self._do_revoke(http, self.refresh_token or self.access_token) - def _do_revoke(self, http_request, token): + def _do_revoke(self, http, token): """Revokes this credential and deletes the stored copy (if it exists). Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - refresh request. + http: an object to be used to make HTTP requests. token: A string used as the token to be revoked. Can be either an access_token or refresh_token. @@ -859,8 +833,13 @@ class OAuth2Credentials(Credentials): """ 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) + token_revoke_uri = _helpers.update_query_params( + self.revoke_uri, query_params) + resp, content = transport.request(http, token_revoke_uri) + if resp.status == http_client.METHOD_NOT_ALLOWED: + body = urllib.parse.urlencode(query_params) + resp, content = transport.request(http, token_revoke_uri, + method='POST', body=body) if resp.status == http_client.OK: self.invalid = True else: @@ -876,23 +855,19 @@ class OAuth2Credentials(Credentials): if self.store: self.store.delete() - def _retrieve_scopes(self, http_request): + def _retrieve_scopes(self, http): """Retrieves the list of authorized scopes from the OAuth2 provider. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - revoke request. + http: an object to be used to make HTTP requests. """ - self._do_retrieve_scopes(http_request, self.access_token) + self._do_retrieve_scopes(http, self.access_token) - def _do_retrieve_scopes(self, http_request, token): + def _do_retrieve_scopes(self, http, token): """Retrieves the list of authorized scopes from the OAuth2 provider. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - refresh request. + http: an object to be used to make HTTP requests. token: A string used as the token to identify the credentials to the provider. @@ -902,13 +877,13 @@ class OAuth2Credentials(Credentials): """ logger.info('Refreshing scopes') query_params = {'access_token': token, 'fields': 'scope'} - token_info_uri = _update_query_params(self.token_info_uri, - query_params) - resp, content = http_request(token_info_uri) + token_info_uri = _helpers.update_query_params( + self.token_info_uri, query_params) + resp, content = transport.request(http, token_info_uri) content = _helpers._from_bytes(content) if resp.status == http_client.OK: d = json.loads(content) - self.scopes = set(util.string_to_scopes(d.get('scope', ''))) + self.scopes = set(_helpers.string_to_scopes(d.get('scope', ''))) else: error_msg = 'Invalid response {0}.'.format(resp.status) try: @@ -977,19 +952,25 @@ class AccessTokenCredentials(OAuth2Credentials): data['user_agent']) return retval - def _refresh(self, http_request): + def _refresh(self, http): + """Refreshes the access token. + + Args: + http: unused HTTP object. + + Raises: + AccessTokenCredentialsError: always + """ raise AccessTokenCredentialsError( 'The access_token is expired or invalid and can\'t be refreshed.') - def _revoke(self, http_request): + def _revoke(self, http): """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. + http: an object to be used to make HTTP requests. """ - self._do_revoke(http_request, self.access_token) + self._do_revoke(http, self.access_token) def _detect_gce_environment(): @@ -1005,21 +986,16 @@ def _detect_gce_environment(): # could lead to false negatives in the event that we are on GCE, but # the metadata resolution was particularly slow. The latter case is # "unlikely". - connection = six.moves.http_client.HTTPConnection( - _GCE_METADATA_HOST, timeout=GCE_METADATA_TIMEOUT) - + http = transport.get_http_object(timeout=GCE_METADATA_TIMEOUT) try: - headers = {_METADATA_FLAVOR_HEADER: _DESIRED_METADATA_FLAVOR} - connection.request('GET', '/', headers=headers) - response = connection.getresponse() - if response.status == http_client.OK: - return (response.getheader(_METADATA_FLAVOR_HEADER) == - _DESIRED_METADATA_FLAVOR) + response, _ = transport.request( + http, _GCE_METADATA_URI, headers=_GCE_HEADERS) + return ( + response.status == http_client.OK and + response.get(_METADATA_FLAVOR_HEADER) == _DESIRED_METADATA_FLAVOR) except socket.error: # socket.timeout or socket.error(64, 'Host is down') logger.info('Timeout attempting to reach GCE metadata service.') return False - finally: - connection.close() def _in_gae_environment(): @@ -1469,7 +1445,7 @@ class AssertionCredentials(GoogleCredentials): AssertionCredentials objects may be safely pickled and unpickled. """ - @util.positional(2) + @_helpers.positional(2) def __init__(self, assertion_type, user_agent=None, token_uri=oauth2client.GOOGLE_TOKEN_URI, revoke_uri=oauth2client.GOOGLE_REVOKE_URI, @@ -1511,15 +1487,13 @@ class AssertionCredentials(GoogleCredentials): """Generate assertion string to be used in the access token request.""" raise NotImplementedError - def _revoke(self, http_request): + def _revoke(self, http): """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. + http: an object to be used to make HTTP requests. """ - self._do_revoke(http_request, self.access_token) + self._do_revoke(http, self.access_token) def sign_blob(self, blob): """Cryptographically sign a blob (of bytes). @@ -1545,7 +1519,7 @@ def _require_crypto_or_die(): raise CryptoUnavailableError('No crypto library available') -@util.positional(2) +@_helpers.positional(2) def verify_id_token(id_token, audience, http=None, cert_uri=ID_TOKEN_VERIFICATION_CERTS): """Verifies a signed JWT id_token. @@ -1572,7 +1546,7 @@ def verify_id_token(id_token, audience, http=None, if http is None: http = transport.get_cached_http() - resp, content = http.request(cert_uri) + resp, content = transport.request(http, cert_uri) if resp.status == http_client.OK: certs = json.loads(_helpers._from_bytes(content)) return crypt.verify_signed_jwt_with_certs(id_token, certs, audience) @@ -1624,7 +1598,7 @@ def _parse_exchange_token_response(content): except Exception: # different JSON libs raise different exceptions, # so we just do a catch-all here - resp = dict(urllib.parse.parse_qsl(content)) + resp = _helpers.parse_unique_urlencoded(content) # some providers respond with 'expires', others with 'expires_in' if resp and 'expires' in resp: @@ -1633,7 +1607,7 @@ def _parse_exchange_token_response(content): return resp -@util.positional(4) +@_helpers.positional(4) def credentials_from_code(client_id, client_secret, scope, code, redirect_uri='postmessage', http=None, user_agent=None, @@ -1641,7 +1615,9 @@ def credentials_from_code(client_id, client_secret, scope, code, auth_uri=oauth2client.GOOGLE_AUTH_URI, revoke_uri=oauth2client.GOOGLE_REVOKE_URI, device_uri=oauth2client.GOOGLE_DEVICE_URI, - token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI): + token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, + pkce=False, + code_verifier=None): """Exchanges an authorization code for an OAuth2Credentials object. Args: @@ -1665,6 +1641,15 @@ def credentials_from_code(client_id, client_secret, scope, code, device_uri: string, URI for device authorization endpoint. For convenience defaults to Google's endpoints but any OAuth 2.0 provider can be used. + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. Returns: An OAuth2Credentials object. @@ -1675,16 +1660,20 @@ 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=auth_uri, - token_uri=token_uri, revoke_uri=revoke_uri, + user_agent=user_agent, + auth_uri=auth_uri, + token_uri=token_uri, + revoke_uri=revoke_uri, device_uri=device_uri, - token_info_uri=token_info_uri) + token_info_uri=token_info_uri, + pkce=pkce, + code_verifier=code_verifier) credentials = flow.step2_exchange(code, http=http) return credentials -@util.positional(3) +@_helpers.positional(3) def credentials_from_clientsecrets_and_code(filename, scope, code, message=None, redirect_uri='postmessage', @@ -1713,6 +1702,15 @@ def credentials_from_clientsecrets_and_code(filename, scope, code, cache: An optional cache service client that implements get() and set() methods. See clientsecrets.loadfile() for details. device_uri: string, OAuth 2.0 device authorization endpoint + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. Returns: An OAuth2Credentials object. @@ -1803,7 +1801,7 @@ class OAuth2WebServerFlow(Flow): OAuth2WebServerFlow objects may be safely pickled and unpickled. """ - @util.positional(4) + @_helpers.positional(4) def __init__(self, client_id, client_secret=None, scope=None, @@ -1816,6 +1814,8 @@ class OAuth2WebServerFlow(Flow): device_uri=oauth2client.GOOGLE_DEVICE_URI, token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, authorization_header=None, + pkce=False, + code_verifier=None, **kwargs): """Constructor for OAuth2WebServerFlow. @@ -1853,6 +1853,15 @@ class OAuth2WebServerFlow(Flow): require a client to authenticate using a header value instead of passing client_secret in the POST body. + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. **kwargs: dict, The keyword arguments are all optional and required parameters for the OAuth calls. """ @@ -1862,7 +1871,7 @@ class OAuth2WebServerFlow(Flow): raise TypeError("The value of scope must not be None") self.client_id = client_id self.client_secret = client_secret - self.scope = util.scopes_to_string(scope) + self.scope = _helpers.scopes_to_string(scope) self.redirect_uri = redirect_uri self.login_hint = login_hint self.user_agent = user_agent @@ -1872,9 +1881,11 @@ class OAuth2WebServerFlow(Flow): self.device_uri = device_uri self.token_info_uri = token_info_uri self.authorization_header = authorization_header + self._pkce = pkce + self.code_verifier = code_verifier self.params = _oauth2_web_server_flow_params(kwargs) - @util.positional(1) + @_helpers.positional(1) def step1_get_authorize_url(self, redirect_uri=None, state=None): """Returns a URI to redirect to the provider. @@ -1912,10 +1923,17 @@ class OAuth2WebServerFlow(Flow): query_params['state'] = state if self.login_hint is not None: query_params['login_hint'] = self.login_hint - query_params.update(self.params) - return _update_query_params(self.auth_uri, query_params) + if self._pkce: + if not self.code_verifier: + self.code_verifier = _pkce.code_verifier() + challenge = _pkce.code_challenge(self.code_verifier) + query_params['code_challenge'] = challenge + query_params['code_challenge_method'] = 'S256' - @util.positional(1) + query_params.update(self.params) + return _helpers.update_query_params(self.auth_uri, query_params) + + @_helpers.positional(1) def step1_get_device_and_user_codes(self, http=None): """Returns a user code and the verification URL where to enter it @@ -1940,8 +1958,8 @@ class OAuth2WebServerFlow(Flow): if http is None: http = transport.get_http_object() - resp, content = http.request(self.device_uri, method='POST', body=body, - headers=headers) + resp, content = transport.request( + http, self.device_uri, method='POST', body=body, headers=headers) content = _helpers._from_bytes(content) if resp.status == http_client.OK: try: @@ -1963,7 +1981,7 @@ class OAuth2WebServerFlow(Flow): pass raise OAuth2DeviceCodeError(error_msg) - @util.positional(2) + @_helpers.positional(2) def step2_exchange(self, code=None, http=None, device_flow_info=None): """Exchanges a code for OAuth2Credentials. @@ -2006,6 +2024,8 @@ class OAuth2WebServerFlow(Flow): } if self.client_secret is not None: post_data['client_secret'] = self.client_secret + if self._pkce: + post_data['code_verifier'] = self.code_verifier if device_flow_info is not None: post_data['grant_type'] = 'http://oauth.net/grant_type/device/1.0' else: @@ -2023,8 +2043,8 @@ class OAuth2WebServerFlow(Flow): if http is None: http = transport.get_http_object() - resp, content = http.request(self.token_uri, method='POST', body=body, - headers=headers) + resp, content = transport.request( + http, self.token_uri, method='POST', body=body, headers=headers) d = _parse_exchange_token_response(content) if resp.status == http_client.OK and 'access_token' in d: access_token = d['access_token'] @@ -2060,10 +2080,10 @@ class OAuth2WebServerFlow(Flow): raise FlowExchangeError(error_msg) -@util.positional(2) +@_helpers.positional(2) def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, cache=None, login_hint=None, - device_uri=None): + device_uri=None, pkce=None, code_verifier=None): """Create a Flow from a clientsecrets file. Will create the right kind of Flow based on the contents of the @@ -2112,10 +2132,11 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, 'login_hint': login_hint, } revoke_uri = client_info.get('revoke_uri') - if revoke_uri is not None: - constructor_kwargs['revoke_uri'] = revoke_uri - if device_uri is not None: - constructor_kwargs['device_uri'] = device_uri + optional = ('revoke_uri', 'device_uri', 'pkce', 'code_verifier') + for param in optional: + if locals()[param] is not None: + constructor_kwargs[param] = locals()[param] + return OAuth2WebServerFlow( client_info['client_id'], client_info['client_secret'], scope, **constructor_kwargs) diff --git a/src/oauth2client/clientsecrets.py b/src/oauth2client/clientsecrets.py index 4b43e664..1598142e 100644 --- a/src/oauth2client/clientsecrets.py +++ b/src/oauth2client/clientsecrets.py @@ -22,7 +22,6 @@ import json import six -__author__ = 'jcgregorio@google.com (Joe Gregorio)' # Properties that make a client_secrets.json file valid. TYPE_WEB = 'web' diff --git a/src/oauth2client/contrib/_metadata.py b/src/oauth2client/contrib/_metadata.py index 10e6a695..1a18bd43 100644 --- a/src/oauth2client/contrib/_metadata.py +++ b/src/oauth2client/contrib/_metadata.py @@ -20,28 +20,25 @@ See https://cloud.google.com/compute/docs/metadata import datetime import json -import httplib2 from six.moves import http_client from six.moves.urllib import parse as urlparse from oauth2client import _helpers from oauth2client import client -from oauth2client import util +from oauth2client import transport METADATA_ROOT = 'http://metadata.google.internal/computeMetadata/v1/' METADATA_HEADERS = {'Metadata-Flavor': 'Google'} -def get(http_request, path, root=METADATA_ROOT, recursive=None): +def get(http, path, root=METADATA_ROOT, recursive=None): """Fetch a resource from the metadata server. Args: + http: an object to be used to make HTTP requests. path: A string indicating the resource to retrieve. For example, 'instance/service-accounts/defualt' - http_request: A callable that matches the method - signature of httplib2.Http.request. Used to make the request to the - metadataserver. root: A string indicating the full path to the metadata server root. recursive: A boolean indicating whether to do a recursive query of metadata. See @@ -51,15 +48,14 @@ def get(http_request, path, root=METADATA_ROOT, recursive=None): A dictionary if the metadata server returns JSON, otherwise a string. Raises: - httplib2.Httplib2Error if an error corrured while retrieving metadata. + http_client.HTTPException if an error corrured while + retrieving metadata. """ url = urlparse.urljoin(root, path) - url = util._add_query_parameter(url, 'recursive', recursive) + url = _helpers._add_query_parameter(url, 'recursive', recursive) - response, content = http_request( - url, - headers=METADATA_HEADERS - ) + response, content = transport.request( + http, url, headers=METADATA_HEADERS) if response.status == http_client.OK: decoded = _helpers._from_bytes(content) @@ -68,21 +64,20 @@ def get(http_request, path, root=METADATA_ROOT, recursive=None): else: return decoded else: - raise httplib2.HttpLib2Error( + raise http_client.HTTPException( 'Failed to retrieve {0} from the Google Compute Engine' 'metadata service. Response:\n{1}'.format(url, response)) -def get_service_account_info(http_request, service_account='default'): +def get_service_account_info(http, service_account='default'): """Get information about a service account from the metadata server. Args: + http: an object to be used to make HTTP requests. service_account: An email specifying the service account for which to look up information. Default will be information for the "default" service account of the current compute engine instance. - http_request: A callable that matches the method - signature of httplib2.Http.request. Used to make the request to the - metadata server. + Returns: A dictionary with information about the specified service account, for example: @@ -94,21 +89,19 @@ def get_service_account_info(http_request, service_account='default'): } """ return get( - http_request, + http, 'instance/service-accounts/{0}/'.format(service_account), recursive=True) -def get_token(http_request, service_account='default'): +def get_token(http, service_account='default'): """Fetch an oauth token for the Args: + http: an object to be used to make HTTP requests. service_account: An email specifying the service account this token should represent. Default will be a token for the "default" service account of the current compute engine instance. - http_request: A callable that matches the method - signature of httplib2.Http.request. Used to make the request to the - metadataserver. Returns: A tuple of (access token, token expiration), where access token is the @@ -116,7 +109,7 @@ def get_token(http_request, service_account='default'): that indicates when the access token will expire. """ token_json = get( - http_request, + http, 'instance/service-accounts/{0}/token'.format(service_account)) token_expiry = client._UTCNOW() + datetime.timedelta( seconds=token_json['expires_in']) diff --git a/src/oauth2client/contrib/appengine.py b/src/oauth2client/contrib/appengine.py index 661105ed..c1326eeb 100644 --- a/src/oauth2client/contrib/appengine.py +++ b/src/oauth2client/contrib/appengine.py @@ -29,13 +29,13 @@ from google.appengine.api import memcache from google.appengine.api import users from google.appengine.ext import db from google.appengine.ext.webapp.util import login_required -import httplib2 import webapp2 as webapp import oauth2client +from oauth2client import _helpers from oauth2client import client from oauth2client import clientsecrets -from oauth2client import util +from oauth2client import transport from oauth2client.contrib import xsrfutil # This is a temporary fix for a Google internal issue. @@ -45,8 +45,6 @@ except ImportError: # pragma: NO COVER _appengine_ndb = None -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - logger = logging.getLogger(__name__) OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns' @@ -131,7 +129,7 @@ class AppAssertionCredentials(client.AssertionCredentials): information to generate and refresh its own access tokens. """ - @util.positional(2) + @_helpers.positional(2) def __init__(self, scope, **kwargs): """Constructor for AppAssertionCredentials @@ -143,7 +141,7 @@ class AppAssertionCredentials(client.AssertionCredentials): or unspecified, the default service account for the app is used. """ - self.scope = util.scopes_to_string(scope) + self.scope = _helpers.scopes_to_string(scope) self._kwargs = kwargs self.service_account_id = kwargs.get('service_account_id', None) self._service_account_email = None @@ -157,17 +155,15 @@ class AppAssertionCredentials(client.AssertionCredentials): data = json.loads(json_data) return AppAssertionCredentials(data['scope']) - def _refresh(self, http_request): - """Refreshes the access_token. + def _refresh(self, http): + """Refreshes the access token. Since the underlying App Engine app_identity implementation does its own caching we can skip all the storage hoops and just to a refresh using the API. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - refresh request. + http: unused HTTP object Raises: AccessTokenRefreshError: When the refresh fails. @@ -305,7 +301,7 @@ class StorageByKeyName(client.Storage): and that entities are stored by key_name. """ - @util.positional(4) + @_helpers.positional(4) def __init__(self, model, key_name, property_name, cache=None, user=None): """Constructor for Storage. @@ -523,7 +519,7 @@ class OAuth2Decorator(object): flow = property(get_flow, set_flow) - @util.positional(4) + @_helpers.positional(4) def __init__(self, client_id, client_secret, scope, auth_uri=oauth2client.GOOGLE_AUTH_URI, token_uri=oauth2client.GOOGLE_TOKEN_URI, @@ -590,7 +586,7 @@ class OAuth2Decorator(object): self.credentials = None self._client_id = client_id self._client_secret = client_secret - self._scope = util.scopes_to_string(scope) + self._scope = _helpers.scopes_to_string(scope) self._auth_uri = auth_uri self._token_uri = token_uri self._revoke_uri = revoke_uri @@ -742,7 +738,8 @@ class OAuth2Decorator(object): *args: Positional arguments passed to httplib2.Http constructor. **kwargs: Positional arguments passed to httplib2.Http constructor. """ - return self.credentials.authorize(httplib2.Http(*args, **kwargs)) + return self.credentials.authorize( + transport.get_http_object(*args, **kwargs)) @property def callback_path(self): @@ -804,7 +801,7 @@ class OAuth2Decorator(object): if (decorator._token_response_param and credentials.token_response): resp_json = json.dumps(credentials.token_response) - redirect_uri = util._add_query_parameter( + redirect_uri = _helpers._add_query_parameter( redirect_uri, decorator._token_response_param, resp_json) @@ -848,7 +845,7 @@ class OAuth2DecoratorFromClientSecrets(OAuth2Decorator): """ - @util.positional(3) + @_helpers.positional(3) def __init__(self, filename, scope, message=None, cache=None, **kwargs): """Constructor @@ -891,7 +888,7 @@ class OAuth2DecoratorFromClientSecrets(OAuth2Decorator): self._message = 'Please configure your application for OAuth 2.0.' -@util.positional(2) +@_helpers.positional(2) def oauth2decorator_from_clientsecrets(filename, scope, message=None, cache=None): """Creates an OAuth2Decorator populated from a clientsecrets file. diff --git a/src/oauth2client/contrib/devshell.py b/src/oauth2client/contrib/devshell.py index b8bb9780..c1906eb6 100644 --- a/src/oauth2client/contrib/devshell.py +++ b/src/oauth2client/contrib/devshell.py @@ -117,7 +117,12 @@ class DevshellCredentials(client.GoogleCredentials): user_agent) self._refresh(None) - def _refresh(self, http_request): + def _refresh(self, http): + """Refreshes the access token. + + Args: + http: unused HTTP object + """ self.devshell_response = _SendRecv() self.access_token = self.devshell_response.access_token expires_in = self.devshell_response.expires_in diff --git a/src/oauth2client/contrib/django_util/__init__.py b/src/oauth2client/contrib/django_util/__init__.py index 5449e32e..644a8f9f 100644 --- a/src/oauth2client/contrib/django_util/__init__.py +++ b/src/oauth2client/contrib/django_util/__init__.py @@ -52,6 +52,9 @@ Add the helper to your INSTALLED_APPS: This helper also requires the Django Session Middleware, so ``django.contrib.sessions.middleware`` should be in INSTALLED_APPS as well. +MIDDLEWARE or MIDDLEWARE_CLASSES (in Django versions <1.10) should also +contain the string 'django.contrib.sessions.middleware.SessionMiddleware'. + Add the client secrets created earlier to the settings. You can either specify the path to the credentials file in JSON format @@ -228,10 +231,10 @@ import importlib import django.conf from django.core import exceptions from django.core import urlresolvers -import httplib2 from six.moves.urllib import parse from oauth2client import clientsecrets +from oauth2client import transport from oauth2client.contrib import dictionary_storage from oauth2client.contrib.django_util import storage @@ -335,16 +338,26 @@ class OAuth2Settings(object): self.request_prefix = getattr(settings_instance, 'GOOGLE_OAUTH2_REQUEST_ATTRIBUTE', GOOGLE_OAUTH2_REQUEST_ATTRIBUTE) - self.client_id, self.client_secret = \ - _get_oauth2_client_id_and_secret(settings_instance) + info = _get_oauth2_client_id_and_secret(settings_instance) + self.client_id, self.client_secret = info - if ('django.contrib.sessions.middleware.SessionMiddleware' - not in settings_instance.MIDDLEWARE_CLASSES): + # Django 1.10 deprecated MIDDLEWARE_CLASSES in favor of MIDDLEWARE + middleware_settings = getattr(settings_instance, 'MIDDLEWARE', None) + if middleware_settings is None: + middleware_settings = getattr( + settings_instance, 'MIDDLEWARE_CLASSES', None) + if middleware_settings is None: raise exceptions.ImproperlyConfigured( - 'The Google OAuth2 Helper requires session middleware to ' - 'be installed. Edit your MIDDLEWARE_CLASSES setting' - ' to include \'django.contrib.sessions.middleware.' - 'SessionMiddleware\'.') + 'Django settings has neither MIDDLEWARE nor MIDDLEWARE_CLASSES' + 'configured') + + if ('django.contrib.sessions.middleware.SessionMiddleware' not in + middleware_settings): + raise exceptions.ImproperlyConfigured( + 'The Google OAuth2 Helper requires session middleware to ' + 'be installed. Edit your MIDDLEWARE_CLASSES or MIDDLEWARE ' + 'setting to include \'django.contrib.sessions.middleware.' + 'SessionMiddleware\'.') (self.storage_model, self.storage_model_user_property, self.storage_model_credentials_property) = _get_storage_model() @@ -470,8 +483,7 @@ class UserOAuth2(object): @property def http(self): - """Helper method to create an HTTP client authorized with OAuth2 - credentials.""" + """Helper: create HTTP client authorized with OAuth2 credentials.""" if self.has_credentials(): - return self.credentials.authorize(httplib2.Http()) + return self.credentials.authorize(transport.get_http_object()) return None diff --git a/src/oauth2client/contrib/django_util/views.py b/src/oauth2client/contrib/django_util/views.py index 4d8ae03c..009b544c 100644 --- a/src/oauth2client/contrib/django_util/views.py +++ b/src/oauth2client/contrib/django_util/views.py @@ -22,13 +22,13 @@ in the configured storage.""" import hashlib import json import os -import pickle from django import http from django import shortcuts from django.conf import settings from django.core import urlresolvers from django.shortcuts import redirect +import jsonpickle from six.moves.urllib import parse from oauth2client import client @@ -71,7 +71,7 @@ def _make_flow(request, scopes, return_url=None): urlresolvers.reverse("google_oauth:callback"))) flow_key = _FLOW_KEY.format(csrf_token) - request.session[flow_key] = pickle.dumps(flow) + request.session[flow_key] = jsonpickle.encode(flow) return flow @@ -89,7 +89,7 @@ def _get_flow_for_token(csrf_token, request): CSRF token. """ flow_pickle = request.session.get(_FLOW_KEY.format(csrf_token), None) - return None if flow_pickle is None else pickle.loads(flow_pickle) + return None if flow_pickle is None else jsonpickle.decode(flow_pickle) def oauth2_callback(request): @@ -170,7 +170,10 @@ def oauth2_authorize(request): A redirect to Google OAuth2 Authorization. """ return_url = request.GET.get('return_url', None) + if not return_url: + return_url = request.META.get('HTTP_REFERER', '/') + scopes = request.GET.getlist('scopes', django_util.oauth2_settings.scopes) # Model storage (but not session storage) requires a logged in user if django_util.oauth2_settings.storage_model: if not request.user.is_authenticated(): @@ -178,13 +181,11 @@ def oauth2_authorize(request): settings.LOGIN_URL, parse.quote(request.get_full_path()))) # This checks for the case where we ended up here because of a logged # out user but we had credentials for it in the first place - elif get_storage(request).get() is not None: - return redirect(return_url) + else: + user_oauth = django_util.UserOAuth2(request, scopes, return_url) + if user_oauth.has_credentials(): + return redirect(return_url) - scopes = request.GET.getlist('scopes', django_util.oauth2_settings.scopes) - - if not return_url: - return_url = request.META.get('HTTP_REFERER', '/') flow = _make_flow(request=request, scopes=scopes, return_url=return_url) auth_url = flow.step1_get_authorize_url() return shortcuts.redirect(auth_url) diff --git a/src/oauth2client/contrib/flask_util.py b/src/oauth2client/contrib/flask_util.py index 47c3df1b..6d7d8f7f 100644 --- a/src/oauth2client/contrib/flask_util.py +++ b/src/oauth2client/contrib/flask_util.py @@ -179,16 +179,14 @@ try: except ImportError: # pragma: NO COVER raise ImportError('The flask utilities require flask 0.9 or newer.') -import httplib2 import six.moves.http_client as httplib from oauth2client import client from oauth2client import clientsecrets +from oauth2client import transport from oauth2client.contrib import dictionary_storage -__author__ = 'jonwayne@google.com (Jon Wayne Parrott)' - _DEFAULT_SCOPES = ('email',) _CREDENTIALS_KEY = 'google_oauth2_credentials' _FLOW_KEY = 'google_oauth2_flow_{0}' @@ -553,4 +551,5 @@ class UserOAuth2(object): """ if not self.credentials: raise ValueError('No credentials available.') - return self.credentials.authorize(httplib2.Http(*args, **kwargs)) + return self.credentials.authorize( + transport.get_http_object(*args, **kwargs)) diff --git a/src/oauth2client/contrib/gce.py b/src/oauth2client/contrib/gce.py index f3a6ca18..aaab15ff 100644 --- a/src/oauth2client/contrib/gce.py +++ b/src/oauth2client/contrib/gce.py @@ -20,14 +20,12 @@ Utilities for making it easier to use OAuth 2.0 on Google Compute Engine. import logging import warnings -import httplib2 +from six.moves import http_client from oauth2client import client from oauth2client.contrib import _metadata -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - logger = logging.getLogger(__name__) _SCOPES_WARNING = """\ @@ -98,44 +96,40 @@ class AppAssertionCredentials(client.AssertionCredentials): Returns: A set of strings containing the canonical list of scopes. """ - self._retrieve_info(http.request) + self._retrieve_info(http) return self.scopes - def _retrieve_info(self, http_request): - """Validates invalid service accounts by retrieving service account info. + def _retrieve_info(self, http): + """Retrieves service account info for invalid credentials. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - request to the metadata server + http: an object to be used to make HTTP requests. """ if self.invalid: info = _metadata.get_service_account_info( - http_request, + http, service_account=self.service_account_email or 'default') self.invalid = False self.service_account_email = info['email'] self.scopes = info['scopes'] - def _refresh(self, http_request): - """Refreshes the access_token. + def _refresh(self, http): + """Refreshes the access token. Skip all the storage hoops and just refresh using the API. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make - the refresh request. + http: an object to be used to make HTTP requests. Raises: HttpAccessTokenRefreshError: When the refresh fails. """ try: - self._retrieve_info(http_request) + self._retrieve_info(http) self.access_token, self.token_expiry = _metadata.get_token( - http_request, service_account=self.service_account_email) - except httplib2.HttpLib2Error as e: - raise client.HttpAccessTokenRefreshError(str(e)) + http, service_account=self.service_account_email) + except http_client.HTTPException as err: + raise client.HttpAccessTokenRefreshError(str(err)) @property def serialization_data(self): diff --git a/src/oauth2client/contrib/keyring_storage.py b/src/oauth2client/contrib/keyring_storage.py index f4f2e30d..4af94488 100644 --- a/src/oauth2client/contrib/keyring_storage.py +++ b/src/oauth2client/contrib/keyring_storage.py @@ -24,9 +24,6 @@ import keyring from oauth2client import client -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - - class Storage(client.Storage): """Store and retrieve a single credential to and from the keyring. diff --git a/src/oauth2client/contrib/xsrfutil.py b/src/oauth2client/contrib/xsrfutil.py index c03e679f..7c3ec035 100644 --- a/src/oauth2client/contrib/xsrfutil.py +++ b/src/oauth2client/contrib/xsrfutil.py @@ -20,12 +20,7 @@ import hmac import time from oauth2client import _helpers -from oauth2client import util -__authors__ = [ - '"Doug Coker" ', - '"Joe Gregorio" ', -] # Delimiter character DELIMITER = b':' @@ -34,7 +29,7 @@ DELIMITER = b':' DEFAULT_TIMEOUT_SECS = 60 * 60 -@util.positional(2) +@_helpers.positional(2) def generate_token(key, user_id, action_id='', when=None): """Generates a URL-safe token for the given user, action, time tuple. @@ -62,7 +57,7 @@ def generate_token(key, user_id, action_id='', when=None): return token -@util.positional(3) +@_helpers.positional(3) def validate_token(key, token, user_id, action_id="", current_time=None): """Validates that the given token authorizes the user for the action. diff --git a/src/oauth2client/file.py b/src/oauth2client/file.py index feede11c..3551c80d 100644 --- a/src/oauth2client/file.py +++ b/src/oauth2client/file.py @@ -21,16 +21,10 @@ credentials. import os import threading +from oauth2client import _helpers from oauth2client import client -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - - -class CredentialsFileSymbolicLinkError(Exception): - """Credentials files must not be symbolic links.""" - - class Storage(client.Storage): """Store and retrieve a single credential to and from a file.""" @@ -38,11 +32,6 @@ class Storage(client.Storage): super(Storage, self).__init__(lock=threading.Lock()) self._filename = filename - def _validate_file(self): - if os.path.islink(self._filename): - raise CredentialsFileSymbolicLinkError( - 'File: {0} is a symbolic link.'.format(self._filename)) - def locked_get(self): """Retrieve Credential from file. @@ -50,10 +39,10 @@ class Storage(client.Storage): oauth2client.client.Credentials Raises: - CredentialsFileSymbolicLinkError if the file is a symbolic link. + IOError if the file is a symbolic link. """ credentials = None - self._validate_file() + _helpers.validate_file(self._filename) try: f = open(self._filename, 'rb') content = f.read() @@ -89,10 +78,10 @@ class Storage(client.Storage): credentials: Credentials, the credentials to store. Raises: - CredentialsFileSymbolicLinkError if the file is a symbolic link. + IOError if the file is a symbolic link. """ self._create_file_if_needed() - self._validate_file() + _helpers.validate_file(self._filename) f = open(self._filename, 'w') f.write(credentials.to_json()) f.close() diff --git a/src/oauth2client/service_account.py b/src/oauth2client/service_account.py index bdcfd69d..540bfaaa 100644 --- a/src/oauth2client/service_account.py +++ b/src/oauth2client/service_account.py @@ -25,7 +25,6 @@ from oauth2client import _helpers from oauth2client import client from oauth2client import crypt from oauth2client import transport -from oauth2client import util _PASSWORD_DEFAULT = 'notasecret' @@ -110,7 +109,7 @@ class ServiceAccountCredentials(client.AssertionCredentials): self._service_account_email = service_account_email self._signer = signer - self._scopes = util.scopes_to_string(scopes) + self._scopes = _helpers.scopes_to_string(scopes) self._private_key_id = private_key_id self.client_id = client_id self._user_agent = user_agent @@ -650,9 +649,22 @@ class _JWTAccessCredentials(ServiceAccountCredentials): return result def refresh(self, http): + """Refreshes the access_token. + + The HTTP object is unused since no request needs to be made to + get a new token, it can just be generated locally. + + Args: + http: unused HTTP object + """ self._refresh(None) - def _refresh(self, http_request): + def _refresh(self, http): + """Refreshes the access_token. + + Args: + http: unused HTTP object + """ self.access_token, self.token_expiry = self._create_token() def _create_token(self, additional_claims=None): diff --git a/src/oauth2client/tools.py b/src/oauth2client/tools.py index a5597752..b882429f 100644 --- a/src/oauth2client/tools.py +++ b/src/oauth2client/tools.py @@ -30,11 +30,10 @@ from six.moves import http_client from six.moves import input from six.moves import urllib +from oauth2client import _helpers from oauth2client import client -from oauth2client import util -__author__ = 'jcgregorio@google.com (Joe Gregorio)' __all__ = ['argparser', 'run_flow', 'message_if_missing'] _CLIENT_SECRETS_MESSAGE = """WARNING: Please configure OAuth 2.0 @@ -123,22 +122,22 @@ class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler): if an error occurred. """ self.send_response(http_client.OK) - self.send_header("Content-type", "text/html") + self.send_header('Content-type', 'text/html') self.end_headers() - query = self.path.split('?', 1)[-1] - query = dict(urllib.parse.parse_qsl(query)) + parts = urllib.parse.urlparse(self.path) + query = _helpers.parse_unique_urlencoded(parts.query) self.server.query_params = query self.wfile.write( - b"Authentication Status") + b'Authentication Status') self.wfile.write( - b"

The authentication flow has completed.

") - self.wfile.write(b"") + b'

The authentication flow has completed.

') + self.wfile.write(b'') def log_message(self, format, *args): """Do not log messages to stdout while running as cmd. line program.""" -@util.positional(3) +@_helpers.positional(3) def run_flow(flow, storage, flags=None, http=None): """Core code for a command-line application. @@ -218,16 +217,6 @@ def run_flow(flow, storage, flags=None, http=None): flow.redirect_uri = oauth_callback authorize_url = flow.step1_get_authorize_url() - if flags.short_url: - try: - from googleapiclient.discovery import build - service = build('urlshortener', 'v1', http=http) - url_result = service.url().insert(body={'longUrl': authorize_url}, - key=u'AIzaSyBlmgbii8QfJSYmC9VTMOfqrAt5Vj5wtzE').execute() - authorize_url = url_result['id'] - except: - pass - if not flags.noauth_local_webserver: import webbrowser webbrowser.open(authorize_url, new=1, autoraise=True) diff --git a/src/oauth2client/transport.py b/src/oauth2client/transport.py index 8dbc60d8..79a61f1c 100644 --- a/src/oauth2client/transport.py +++ b/src/oauth2client/transport.py @@ -18,7 +18,7 @@ import httplib2 import six from six.moves import http_client -from oauth2client._helpers import _to_bytes +from oauth2client import _helpers _LOGGER = logging.getLogger(__name__) @@ -58,13 +58,19 @@ def get_cached_http(): return _CACHED_HTTP -def get_http_object(): +def get_http_object(*args, **kwargs): """Return a new HTTP object. + Args: + *args: tuple, The positional arguments to be passed when + contructing a new HTTP object. + **kwargs: dict, The keyword arguments to be passed when + contructing a new HTTP object. + Returns: httplib2.Http, an HTTP object. """ - return httplib2.Http() + return httplib2.Http(*args, **kwargs) def _initialize_headers(headers): @@ -121,7 +127,7 @@ def clean_headers(headers): k = str(k) if not isinstance(v, six.binary_type): v = str(v) - clean[_to_bytes(k)] = _to_bytes(v) + clean[_helpers._to_bytes(k)] = _helpers._to_bytes(v) except UnicodeEncodeError: from oauth2client.client import NonAsciiHeaderError raise NonAsciiHeaderError(k, ': ', v) @@ -164,9 +170,9 @@ def wrap_http_for_auth(credentials, http): _STREAM_PROPERTIES): body_stream_position = body.tell() - resp, content = orig_request_method(uri, method, body, - clean_headers(headers), - redirections, connection_type) + resp, content = request(orig_request_method, uri, method, body, + clean_headers(headers), + redirections, connection_type) # A stored token may expire between the time it is retrieved and # the time the request is made, so we may need to try twice. @@ -182,9 +188,9 @@ def wrap_http_for_auth(credentials, http): if body_stream_position is not None: body.seek(body_stream_position) - resp, content = orig_request_method(uri, method, body, - clean_headers(headers), - redirections, connection_type) + resp, content = request(orig_request_method, uri, method, body, + clean_headers(headers), + redirections, connection_type) return resp, content @@ -192,7 +198,7 @@ def wrap_http_for_auth(credentials, http): http.request = new_request # Set credentials as a property of the request method. - setattr(http.request, 'credentials', credentials) + http.request.credentials = credentials def wrap_http_for_jwt_access(credentials, http): @@ -222,9 +228,9 @@ def wrap_http_for_jwt_access(credentials, http): if (credentials.access_token is None or credentials.access_token_expired): credentials.refresh(None) - return authenticated_request_method(uri, method, body, - headers, redirections, - connection_type) + return request(authenticated_request_method, uri, + method, body, headers, redirections, + connection_type) else: # If we don't have an 'aud' (audience) claim, # create a 1-time token with the uri root as the audience @@ -234,12 +240,46 @@ def wrap_http_for_jwt_access(credentials, http): token, unused_expiry = credentials._create_token({'aud': uri_root}) headers['Authorization'] = 'Bearer ' + token - return orig_request_method(uri, method, body, - clean_headers(headers), - redirections, connection_type) + return request(orig_request_method, uri, method, body, + clean_headers(headers), + redirections, connection_type) # Replace the request method with our own closure. http.request = new_request + # Set credentials as a property of the request method. + http.request.credentials = credentials + + +def request(http, uri, method='GET', body=None, headers=None, + redirections=httplib2.DEFAULT_MAX_REDIRECTS, + connection_type=None): + """Make an HTTP request with an HTTP object and arguments. + + Args: + http: httplib2.Http, an http object to be used to make requests. + uri: string, The URI to be requested. + method: string, The HTTP method to use for the request. Defaults + to 'GET'. + body: string, The payload / body in HTTP request. By default + there is no payload. + headers: dict, Key-value pairs of request headers. By default + there are no headers. + redirections: int, The number of allowed 203 redirects for + the request. Defaults to 5. + connection_type: httplib.HTTPConnection, a subclass to be used for + establishing connection. If not set, the type + will be determined from the ``uri``. + + Returns: + tuple, a pair of a httplib2.Response with the status code and other + headers and the bytes of the content returned. + """ + # NOTE: Allowing http or http.request is temporary (See Issue 601). + http_callable = getattr(http, 'request', http) + return http_callable(uri, method=method, body=body, headers=headers, + redirections=redirections, + connection_type=connection_type) + _CACHED_HTTP = httplib2.Http(MemoryCache())