diff options
Diffstat (limited to 'oauth2client/client.py')
-rw-r--r-- | oauth2client/client.py | 277 |
1 files changed, 120 insertions, 157 deletions
diff --git a/oauth2client/client.py b/oauth2client/client.py index 7618960..8956443 100644 --- a/oauth2client/client.py +++ b/oauth2client/client.py @@ -34,11 +34,13 @@ 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: @@ -98,20 +100,20 @@ AccessTokenInfo = collections.namedtuple( DEFAULT_ENV_NAME = 'UNKNOWN' # If set to True _get_environment avoid GCE check (_detect_gce_environment) -NO_GCE_CHECK = os.getenv('NO_GCE_CHECK', 'False') +NO_GCE_CHECK = os.environ.setdefault('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.getenv('GCE_METADATA_TIMEOUT', 3)) + GCE_METADATA_TIMEOUT = int( + os.environ.setdefault('GCE_METADATA_TIMEOUT', '3')) except ValueError: # pragma: NO COVER GCE_METADATA_TIMEOUT = 3 _SERVER_SOFTWARE = 'SERVER_SOFTWARE' -_GCE_METADATA_URI = 'http://' + os.getenv('GCE_METADATA_IP', '169.254.169.254') -_METADATA_FLAVOR_HEADER = 'metadata-flavor' # lowercase header +_GCE_METADATA_HOST = '169.254.169.254' +_METADATA_FLAVOR_HEADER = 'Metadata-Flavor' _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). @@ -438,6 +440,23 @@ 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. @@ -447,11 +466,11 @@ class OAuth2Credentials(Credentials): OAuth2Credentials objects may be safely pickled and unpickled. """ - @_helpers.positional(8) + @util.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, - token_info_uri=None, id_token_jwt=None): + token_info_uri=None): """Create an instance of OAuth2Credentials. This constructor is not usually called by the user, instead @@ -474,11 +493,8 @@ class OAuth2Credentials(Credentials): because some providers (e.g. wordpress.com) include extra fields that clients may want. scopes: list, authorized scopes for these credentials. - token_info_uri: string, the URI for the token info endpoint. - Defaults to None; scopes can not be refreshed if - this is None. - id_token_jwt: string, the encoded and signed identity JWT. The - decoded version of this is stored in id_token. + token_info_uri: string, the URI for the token info endpoint. Defaults + to None; scopes can not be refreshed if this is None. Notes: store: callable, A callable that when passed a Credential @@ -496,9 +512,8 @@ class OAuth2Credentials(Credentials): self.user_agent = user_agent self.revoke_uri = revoke_uri self.id_token = id_token - self.id_token_jwt = id_token_jwt self.token_response = token_response - self.scopes = set(_helpers.string_to_scopes(scopes or [])) + self.scopes = set(util.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 @@ -542,7 +557,7 @@ class OAuth2Credentials(Credentials): http: httplib2.Http, an http object to be used to make the refresh request. """ - self._refresh(http) + self._refresh(http.request) def revoke(self, http): """Revokes a refresh_token and makes the credentials void. @@ -551,7 +566,7 @@ class OAuth2Credentials(Credentials): http: httplib2.Http, an http object to be used to make the revoke request. """ - self._revoke(http) + self._revoke(http.request) def apply(self, headers): """Add the authorization to the headers. @@ -577,7 +592,7 @@ class OAuth2Credentials(Credentials): not have scopes. In both cases, you can use refresh_scopes() to obtain the canonical set of scopes. """ - scopes = _helpers.string_to_scopes(scopes) + scopes = util.string_to_scopes(scopes) return set(scopes).issubset(self.scopes) def retrieve_scopes(self, http): @@ -592,7 +607,7 @@ class OAuth2Credentials(Credentials): Returns: A set of strings containing the canonical list of scopes. """ - self._retrieve_scopes(http) + self._retrieve_scopes(http.request) return self.scopes @classmethod @@ -625,7 +640,6 @@ class OAuth2Credentials(Credentials): data['user_agent'], revoke_uri=data.get('revoke_uri', None), id_token=data.get('id_token', None), - id_token_jwt=data.get('id_token_jwt', None), token_response=data.get('token_response', None), scopes=data.get('scopes', None), token_info_uri=data.get('token_info_uri', None)) @@ -732,7 +746,7 @@ class OAuth2Credentials(Credentials): return headers - def _refresh(self, http): + def _refresh(self, http_request): """Refreshes the access_token. This method first checks by reading the Storage object if available. @@ -740,13 +754,15 @@ class OAuth2Credentials(Credentials): refresh is completed. Args: - http: an object to be used to make HTTP requests. + http_request: callable, a callable that matches the method + signature of httplib2.Http.request, used to make the + refresh request. Raises: HttpAccessTokenRefreshError: When the refresh fails. """ if not self.store: - self._do_refresh_request(http) + self._do_refresh_request(http_request) else: self.store.acquire_lock() try: @@ -758,15 +774,17 @@ class OAuth2Credentials(Credentials): logger.info('Updated access_token read from Storage') self._updateFromCredential(new_cred) else: - self._do_refresh_request(http) + self._do_refresh_request(http_request) finally: self.store.release_lock() - def _do_refresh_request(self, http): + def _do_refresh_request(self, http_request): """Refresh the access_token using the refresh_token. Args: - http: an object to be used to make HTTP requests. + http_request: callable, a callable that matches the method + signature of httplib2.Http.request, used to make the + refresh request. Raises: HttpAccessTokenRefreshError: When the refresh fails. @@ -775,9 +793,8 @@ class OAuth2Credentials(Credentials): headers = self._generate_refresh_request_headers() logger.info('Refreshing access_token') - resp, content = transport.request( - http, self.token_uri, method='POST', - body=body, headers=headers) + resp, content = http_request( + self.token_uri, method='POST', body=body, headers=headers) content = _helpers._from_bytes(content) if resp.status == http_client.OK: d = json.loads(content) @@ -791,10 +808,8 @@ class OAuth2Credentials(Credentials): self.token_expiry = None if 'id_token' in d: self.id_token = _extract_id_token(d['id_token']) - self.id_token_jwt = d['id_token'] else: self.id_token = None - self.id_token_jwt = None # On temporary refresh errors, the user does not actually have to # re-authorize, so we unflag here. self.invalid = False @@ -804,7 +819,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: @@ -818,19 +833,23 @@ class OAuth2Credentials(Credentials): pass raise HttpAccessTokenRefreshError(error_msg, status=resp.status) - def _revoke(self, http): + def _revoke(self, http_request): """Revokes this credential and deletes the stored copy (if it exists). Args: - http: an object to be used to make HTTP requests. + http_request: callable, a callable that matches the method + signature of httplib2.Http.request, used to make the + revoke request. """ - self._do_revoke(http, self.refresh_token or self.access_token) + self._do_revoke(http_request, self.refresh_token or self.access_token) - def _do_revoke(self, http, token): + def _do_revoke(self, http_request, token): """Revokes this credential and deletes the stored copy (if it exists). Args: - http: an object to be used to make HTTP requests. + 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. @@ -840,13 +859,8 @@ class OAuth2Credentials(Credentials): """ logger.info('Revoking token') query_params = {'token': token} - 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) + token_revoke_uri = _update_query_params(self.revoke_uri, query_params) + resp, content = http_request(token_revoke_uri) if resp.status == http_client.OK: self.invalid = True else: @@ -862,19 +876,23 @@ class OAuth2Credentials(Credentials): if self.store: self.store.delete() - def _retrieve_scopes(self, http): + def _retrieve_scopes(self, http_request): """Retrieves the list of authorized scopes from the OAuth2 provider. Args: - http: an object to be used to make HTTP requests. + http_request: callable, a callable that matches the method + signature of httplib2.Http.request, used to make the + revoke request. """ - self._do_retrieve_scopes(http, self.access_token) + self._do_retrieve_scopes(http_request, self.access_token) - def _do_retrieve_scopes(self, http, token): + def _do_retrieve_scopes(self, http_request, token): """Retrieves the list of authorized scopes from the OAuth2 provider. Args: - http: an object to be used to make HTTP requests. + 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 identify the credentials to the provider. @@ -884,13 +902,13 @@ class OAuth2Credentials(Credentials): """ logger.info('Refreshing scopes') query_params = {'access_token': token, 'fields': 'scope'} - token_info_uri = _helpers.update_query_params( - self.token_info_uri, query_params) - resp, content = transport.request(http, token_info_uri) + token_info_uri = _update_query_params(self.token_info_uri, + query_params) + resp, content = http_request(token_info_uri) content = _helpers._from_bytes(content) if resp.status == http_client.OK: d = json.loads(content) - self.scopes = set(_helpers.string_to_scopes(d.get('scope', ''))) + self.scopes = set(util.string_to_scopes(d.get('scope', ''))) else: error_msg = 'Invalid response {0}.'.format(resp.status) try: @@ -959,25 +977,19 @@ class AccessTokenCredentials(OAuth2Credentials): data['user_agent']) return retval - def _refresh(self, http): - """Refreshes the access token. - - Args: - http: unused HTTP object. - - Raises: - AccessTokenCredentialsError: always - """ + def _refresh(self, http_request): raise AccessTokenCredentialsError( 'The access_token is expired or invalid and can\'t be refreshed.') - def _revoke(self, http): + def _revoke(self, http_request): """Revokes the access_token and deletes the store if available. Args: - http: an object to be used to make HTTP requests. + http_request: callable, a callable that matches the method + signature of httplib2.Http.request, used to make the + revoke request. """ - self._do_revoke(http, self.access_token) + self._do_revoke(http_request, self.access_token) def _detect_gce_environment(): @@ -993,16 +1005,21 @@ 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". - http = transport.get_http_object(timeout=GCE_METADATA_TIMEOUT) + connection = six.moves.http_client.HTTPConnection( + _GCE_METADATA_HOST, timeout=GCE_METADATA_TIMEOUT) + try: - 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) + 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) 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(): @@ -1452,7 +1469,7 @@ class AssertionCredentials(GoogleCredentials): AssertionCredentials objects may be safely pickled and unpickled. """ - @_helpers.positional(2) + @util.positional(2) def __init__(self, assertion_type, user_agent=None, token_uri=oauth2client.GOOGLE_TOKEN_URI, revoke_uri=oauth2client.GOOGLE_REVOKE_URI, @@ -1494,13 +1511,15 @@ class AssertionCredentials(GoogleCredentials): """Generate assertion string to be used in the access token request.""" raise NotImplementedError - def _revoke(self, http): + def _revoke(self, http_request): """Revokes the access_token and deletes the store if available. Args: - http: an object to be used to make HTTP requests. + http_request: callable, a callable that matches the method + signature of httplib2.Http.request, used to make the + revoke request. """ - self._do_revoke(http, self.access_token) + self._do_revoke(http_request, self.access_token) def sign_blob(self, blob): """Cryptographically sign a blob (of bytes). @@ -1526,7 +1545,7 @@ def _require_crypto_or_die(): raise CryptoUnavailableError('No crypto library available') -@_helpers.positional(2) +@util.positional(2) def verify_id_token(id_token, audience, http=None, cert_uri=ID_TOKEN_VERIFICATION_CERTS): """Verifies a signed JWT id_token. @@ -1553,7 +1572,7 @@ def verify_id_token(id_token, audience, http=None, if http is None: http = transport.get_cached_http() - resp, content = transport.request(http, cert_uri) + resp, content = http.request(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) @@ -1605,7 +1624,7 @@ def _parse_exchange_token_response(content): except Exception: # different JSON libs raise different exceptions, # so we just do a catch-all here - resp = _helpers.parse_unique_urlencoded(content) + resp = dict(urllib.parse.parse_qsl(content)) # some providers respond with 'expires', others with 'expires_in' if resp and 'expires' in resp: @@ -1614,7 +1633,7 @@ def _parse_exchange_token_response(content): return resp -@_helpers.positional(4) +@util.positional(4) def credentials_from_code(client_id, client_secret, scope, code, redirect_uri='postmessage', http=None, user_agent=None, @@ -1622,9 +1641,7 @@ 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, - pkce=False, - code_verifier=None): + token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI): """Exchanges an authorization code for an OAuth2Credentials object. Args: @@ -1648,15 +1665,6 @@ 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. @@ -1667,20 +1675,16 @@ 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, - pkce=pkce, - code_verifier=code_verifier) + token_info_uri=token_info_uri) credentials = flow.step2_exchange(code, http=http) return credentials -@_helpers.positional(3) +@util.positional(3) def credentials_from_clientsecrets_and_code(filename, scope, code, message=None, redirect_uri='postmessage', @@ -1709,15 +1713,6 @@ 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. @@ -1808,7 +1803,7 @@ class OAuth2WebServerFlow(Flow): OAuth2WebServerFlow objects may be safely pickled and unpickled. """ - @_helpers.positional(4) + @util.positional(4) def __init__(self, client_id, client_secret=None, scope=None, @@ -1821,8 +1816,6 @@ 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. @@ -1860,15 +1853,6 @@ 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. """ @@ -1878,7 +1862,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 = _helpers.scopes_to_string(scope) + self.scope = util.scopes_to_string(scope) self.redirect_uri = redirect_uri self.login_hint = login_hint self.user_agent = user_agent @@ -1888,11 +1872,9 @@ 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) - @_helpers.positional(1) + @util.positional(1) def step1_get_authorize_url(self, redirect_uri=None, state=None): """Returns a URI to redirect to the provider. @@ -1930,17 +1912,10 @@ class OAuth2WebServerFlow(Flow): query_params['state'] = state if self.login_hint is not None: query_params['login_hint'] = self.login_hint - 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' - query_params.update(self.params) - return _helpers.update_query_params(self.auth_uri, query_params) + return _update_query_params(self.auth_uri, query_params) - @_helpers.positional(1) + @util.positional(1) def step1_get_device_and_user_codes(self, http=None): """Returns a user code and the verification URL where to enter it @@ -1965,8 +1940,8 @@ class OAuth2WebServerFlow(Flow): if http is None: http = transport.get_http_object() - resp, content = transport.request( - http, self.device_uri, method='POST', body=body, headers=headers) + resp, content = http.request(self.device_uri, method='POST', body=body, + headers=headers) content = _helpers._from_bytes(content) if resp.status == http_client.OK: try: @@ -1988,7 +1963,7 @@ class OAuth2WebServerFlow(Flow): pass raise OAuth2DeviceCodeError(error_msg) - @_helpers.positional(2) + @util.positional(2) def step2_exchange(self, code=None, http=None, device_flow_info=None): """Exchanges a code for OAuth2Credentials. @@ -2031,8 +2006,6 @@ 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: @@ -2050,8 +2023,8 @@ class OAuth2WebServerFlow(Flow): if http is None: http = transport.get_http_object() - resp, content = transport.request( - http, self.token_uri, method='POST', body=body, headers=headers) + resp, content = http.request(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'] @@ -2066,17 +2039,15 @@ class OAuth2WebServerFlow(Flow): token_expiry = delta + _UTCNOW() extracted_id_token = None - id_token_jwt = None if 'id_token' in d: extracted_id_token = _extract_id_token(d['id_token']) - id_token_jwt = d['id_token'] logger.info('Successfully retrieved access token') 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=extracted_id_token, - id_token_jwt=id_token_jwt, token_response=d, scopes=self.scope, + token_response=d, scopes=self.scope, token_info_uri=self.token_info_uri) else: logger.info('Failed to retrieve access token: %s', content) @@ -2089,11 +2060,10 @@ class OAuth2WebServerFlow(Flow): raise FlowExchangeError(error_msg) -@_helpers.positional(2) +@util.positional(2) def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, cache=None, login_hint=None, - device_uri=None, pkce=None, code_verifier=None, - prompt=None): + device_uri=None): """Create a Flow from a clientsecrets file. Will create the right kind of Flow based on the contents of the @@ -2142,17 +2112,10 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, 'login_hint': login_hint, } revoke_uri = client_info.get('revoke_uri') - optional = ( - 'revoke_uri', - 'device_uri', - 'pkce', - 'code_verifier', - 'prompt' - ) - for param in optional: - if locals()[param] is not None: - constructor_kwargs[param] = locals()[param] - + if revoke_uri is not None: + constructor_kwargs['revoke_uri'] = revoke_uri + if device_uri is not None: + constructor_kwargs['device_uri'] = device_uri return OAuth2WebServerFlow( client_info['client_id'], client_info['client_secret'], scope, **constructor_kwargs) |