diff --git a/lib/charset_normalizer/api.py b/lib/charset_normalizer/api.py index b3e198a7..bdc8ed98 100644 --- a/lib/charset_normalizer/api.py +++ b/lib/charset_normalizer/api.py @@ -13,7 +13,7 @@ from .cd import ( mb_encoding_languages, merge_coherence_ratios, ) -from .constant import IANA_SUPPORTED, TOO_BIG_SEQUENCE, TOO_SMALL_SEQUENCE +from .constant import IANA_SUPPORTED, TOO_BIG_SEQUENCE, TOO_SMALL_SEQUENCE, TRACE from .md import mess_ratio from .models import CharsetMatch, CharsetMatches from .utils import ( @@ -25,6 +25,8 @@ from .utils import ( should_strip_sig_or_bom, ) +# Will most likely be controversial +# logging.addLevelName(TRACE, "TRACE") logger = logging.getLogger("charset_normalizer") explain_handler = logging.StreamHandler() explain_handler.setFormatter( @@ -70,19 +72,20 @@ def from_bytes( if explain: previous_logger_level = logger.level # type: int logger.addHandler(explain_handler) - logger.setLevel(logging.DEBUG) + logger.setLevel(TRACE) length = len(sequences) # type: int if length == 0: - logger.warning("Encoding detection on empty bytes, assuming utf_8 intention.") + logger.debug("Encoding detection on empty bytes, assuming utf_8 intention.") if explain: logger.removeHandler(explain_handler) logger.setLevel(previous_logger_level or logging.WARNING) return CharsetMatches([CharsetMatch(sequences, "utf_8", 0.0, False, [], "")]) if cp_isolation is not None: - logger.debug( + logger.log( + TRACE, "cp_isolation is set. use this flag for debugging purpose. " "limited list of encoding allowed : %s.", ", ".join(cp_isolation), @@ -92,7 +95,8 @@ def from_bytes( cp_isolation = [] if cp_exclusion is not None: - logger.debug( + logger.log( + TRACE, "cp_exclusion is set. use this flag for debugging purpose. " "limited list of encoding excluded : %s.", ", ".join(cp_exclusion), @@ -102,7 +106,8 @@ def from_bytes( cp_exclusion = [] if length <= (chunk_size * steps): - logger.debug( + logger.log( + TRACE, "override steps (%i) and chunk_size (%i) as content does not fit (%i byte(s) given) parameters.", steps, chunk_size, @@ -118,16 +123,18 @@ def from_bytes( is_too_large_sequence = len(sequences) >= TOO_BIG_SEQUENCE # type: bool if is_too_small_sequence: - logger.warning( + logger.log( + TRACE, "Trying to detect encoding from a tiny portion of ({}) byte(s).".format( length - ) + ), ) elif is_too_large_sequence: - logger.info( + logger.log( + TRACE, "Using lazy str decoding because the payload is quite large, ({}) byte(s).".format( length - ) + ), ) prioritized_encodings = [] # type: List[str] @@ -138,7 +145,8 @@ def from_bytes( if specified_encoding is not None: prioritized_encodings.append(specified_encoding) - logger.info( + logger.log( + TRACE, "Detected declarative mark in sequence. Priority +1 given for %s.", specified_encoding, ) @@ -157,7 +165,8 @@ def from_bytes( if sig_encoding is not None: prioritized_encodings.append(sig_encoding) - logger.info( + logger.log( + TRACE, "Detected a SIG or BOM mark on first %i byte(s). Priority +1 given for %s.", len(sig_payload), sig_encoding, @@ -188,7 +197,8 @@ def from_bytes( ) # type: bool if encoding_iana in {"utf_16", "utf_32"} and not bom_or_sig_available: - logger.debug( + logger.log( + TRACE, "Encoding %s wont be tested as-is because it require a BOM. Will try some sub-encoder LE/BE.", encoding_iana, ) @@ -197,8 +207,10 @@ def from_bytes( try: is_multi_byte_decoder = is_multi_byte_encoding(encoding_iana) # type: bool except (ModuleNotFoundError, ImportError): - logger.debug( - "Encoding %s does not provide an IncrementalDecoder", encoding_iana + logger.log( + TRACE, + "Encoding %s does not provide an IncrementalDecoder", + encoding_iana, ) continue @@ -219,7 +231,8 @@ def from_bytes( ) except (UnicodeDecodeError, LookupError) as e: if not isinstance(e, LookupError): - logger.debug( + logger.log( + TRACE, "Code page %s does not fit given bytes sequence at ALL. %s", encoding_iana, str(e), @@ -235,7 +248,8 @@ def from_bytes( break if similar_soft_failure_test: - logger.debug( + logger.log( + TRACE, "%s is deemed too similar to code page %s and was consider unsuited already. Continuing!", encoding_iana, encoding_soft_failed, @@ -255,7 +269,8 @@ def from_bytes( ) # type: bool if multi_byte_bonus: - logger.debug( + logger.log( + TRACE, "Code page %s is a multi byte encoding table and it appear that at least one character " "was encoded using n-bytes.", encoding_iana, @@ -285,7 +300,8 @@ def from_bytes( errors="ignore" if is_multi_byte_decoder else "strict", ) # type: str except UnicodeDecodeError as e: # Lazy str loading may have missed something there - logger.debug( + logger.log( + TRACE, "LazyStr Loading: After MD chunk decode, code page %s does not fit given bytes sequence at ALL. %s", encoding_iana, str(e), @@ -337,7 +353,8 @@ def from_bytes( try: sequences[int(50e3) :].decode(encoding_iana, errors="strict") except UnicodeDecodeError as e: - logger.debug( + logger.log( + TRACE, "LazyStr Loading: After final lookup, code page %s does not fit given bytes sequence at ALL. %s", encoding_iana, str(e), @@ -350,7 +367,8 @@ def from_bytes( ) # type: float if mean_mess_ratio >= threshold or early_stop_count >= max_chunk_gave_up: tested_but_soft_failure.append(encoding_iana) - logger.info( + logger.log( + TRACE, "%s was excluded because of initial chaos probing. Gave up %i time(s). " "Computed mean chaos is %f %%.", encoding_iana, @@ -373,7 +391,8 @@ def from_bytes( fallback_u8 = fallback_entry continue - logger.info( + logger.log( + TRACE, "%s passed initial chaos probing. Mean measured chaos is %f %%", encoding_iana, round(mean_mess_ratio * 100, ndigits=3), @@ -385,10 +404,11 @@ def from_bytes( target_languages = mb_encoding_languages(encoding_iana) if target_languages: - logger.debug( + logger.log( + TRACE, "{} should target any language(s) of {}".format( encoding_iana, str(target_languages) - ) + ), ) cd_ratios = [] @@ -406,10 +426,11 @@ def from_bytes( cd_ratios_merged = merge_coherence_ratios(cd_ratios) if cd_ratios_merged: - logger.info( + logger.log( + TRACE, "We detected language {} using {}".format( cd_ratios_merged, encoding_iana - ) + ), ) results.append( @@ -427,8 +448,8 @@ def from_bytes( encoding_iana in [specified_encoding, "ascii", "utf_8"] and mean_mess_ratio < 0.1 ): - logger.info( - "%s is most likely the one. Stopping the process.", encoding_iana + logger.debug( + "Encoding detection: %s is most likely the one.", encoding_iana ) if explain: logger.removeHandler(explain_handler) @@ -436,8 +457,9 @@ def from_bytes( return CharsetMatches([results[encoding_iana]]) if encoding_iana == sig_encoding: - logger.info( - "%s is most likely the one as we detected a BOM or SIG within the beginning of the sequence.", + logger.debug( + "Encoding detection: %s is most likely the one as we detected a BOM or SIG within " + "the beginning of the sequence.", encoding_iana, ) if explain: @@ -447,13 +469,15 @@ def from_bytes( if len(results) == 0: if fallback_u8 or fallback_ascii or fallback_specified: - logger.debug( - "Nothing got out of the detection process. Using ASCII/UTF-8/Specified fallback." + logger.log( + TRACE, + "Nothing got out of the detection process. Using ASCII/UTF-8/Specified fallback.", ) if fallback_specified: logger.debug( - "%s will be used as a fallback match", fallback_specified.encoding + "Encoding detection: %s will be used as a fallback match", + fallback_specified.encoding, ) results.append(fallback_specified) elif ( @@ -465,12 +489,21 @@ def from_bytes( ) or (fallback_u8 is not None) ): - logger.warning("utf_8 will be used as a fallback match") + logger.debug("Encoding detection: utf_8 will be used as a fallback match") results.append(fallback_u8) elif fallback_ascii: - logger.warning("ascii will be used as a fallback match") + logger.debug("Encoding detection: ascii will be used as a fallback match") results.append(fallback_ascii) + if results: + logger.debug( + "Encoding detection: Found %s as plausible (best-candidate) for content. With %i alternatives.", + results.best().encoding, # type: ignore + len(results) - 1, + ) + else: + logger.debug("Encoding detection: Unable to determine any suitable charset.") + if explain: logger.removeHandler(explain_handler) logger.setLevel(previous_logger_level) diff --git a/lib/charset_normalizer/constant.py b/lib/charset_normalizer/constant.py index 3d5d6457..c32f5cf2 100644 --- a/lib/charset_normalizer/constant.py +++ b/lib/charset_normalizer/constant.py @@ -498,3 +498,6 @@ ZH_NAMES = {"big5", "cp950", "big5hkscs", "hz"} # type: Set[str] NOT_PRINTABLE_PATTERN = re_compile(r"[0-9\W\n\r\t]+") LANGUAGE_SUPPORTED_COUNT = len(FREQUENCIES) # type: int + +# Logging LEVEL bellow DEBUG +TRACE = 5 # type: int diff --git a/lib/charset_normalizer/version.py b/lib/charset_normalizer/version.py index a8d66597..69bf0503 100644 --- a/lib/charset_normalizer/version.py +++ b/lib/charset_normalizer/version.py @@ -2,5 +2,5 @@ Expose version """ -__version__ = "2.0.10" +__version__ = "2.0.11" VERSION = __version__.split(".") diff --git a/lib/oauthlib/__init__.py b/lib/oauthlib/__init__.py index a94cf941..5dbffc96 100644 --- a/lib/oauthlib/__init__.py +++ b/lib/oauthlib/__init__.py @@ -12,7 +12,7 @@ import logging from logging import NullHandler __author__ = 'The OAuthlib Community' -__version__ = '3.1.1' +__version__ = '3.2.0' logging.getLogger('oauthlib').addHandler(NullHandler()) diff --git a/lib/oauthlib/oauth2/__init__.py b/lib/oauthlib/oauth2/__init__.py index a6e1cccd..deefb1af 100644 --- a/lib/oauthlib/oauth2/__init__.py +++ b/lib/oauthlib/oauth2/__init__.py @@ -33,3 +33,4 @@ from .rfc6749.grant_types import ( from .rfc6749.request_validator import RequestValidator from .rfc6749.tokens import BearerToken, OAuth2Token from .rfc6749.utils import is_secure_transport +from .rfc8628.clients import DeviceClient diff --git a/lib/oauthlib/oauth2/rfc6749/clients/base.py b/lib/oauthlib/oauth2/rfc6749/clients/base.py index 88065ab3..bb4c1338 100644 --- a/lib/oauthlib/oauth2/rfc6749/clients/base.py +++ b/lib/oauthlib/oauth2/rfc6749/clients/base.py @@ -8,6 +8,10 @@ for consuming OAuth 2.0 RFC6749. """ import time import warnings +import secrets +import re +import hashlib +import base64 from oauthlib.common import generate_token from oauthlib.oauth2.rfc6749 import tokens @@ -61,6 +65,9 @@ class Client: state=None, redirect_url=None, state_generator=generate_token, + code_verifier=None, + code_challenge=None, + code_challenge_method=None, **kwargs): """Initialize a client with commonly used attributes. @@ -99,6 +106,15 @@ class Client: :param state_generator: A no argument state generation callable. Defaults to :py:meth:`oauthlib.common.generate_token`. + + :param code_verifier: PKCE parameter. A cryptographically random string that is used to correlate the + authorization request to the token request. + + :param code_challenge: PKCE parameter. A challenge derived from the code verifier that is sent in the + authorization request, to be verified against later. + + :param code_challenge_method: PKCE parameter. A method that was used to derive code challenge. + Defaults to "plain" if not present in the request. """ self.client_id = client_id @@ -113,6 +129,9 @@ class Client: self.state_generator = state_generator self.state = state self.redirect_url = redirect_url + self.code_verifier = code_verifier + self.code_challenge = code_challenge + self.code_challenge_method = code_challenge_method self.code = None self.expires_in = None self._expires_at = None @@ -471,6 +490,91 @@ class Client: raise ValueError("Invalid token placement.") return uri, headers, body + def create_code_verifier(self, length): + """Create PKCE **code_verifier** used in computing **code_challenge**. + + :param length: REQUIRED. The length of the code_verifier. + + The client first creates a code verifier, "code_verifier", for each + OAuth 2.0 [RFC6749] Authorization Request, in the following manner: + + code_verifier = high-entropy cryptographic random STRING using the + unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + from Section 2.3 of [RFC3986], with a minimum length of 43 characters + and a maximum length of 128 characters. + + .. _`Section 4.1`: https://tools.ietf.org/html/rfc7636#section-4.1 + """ + code_verifier = None + + if not length >= 43: + raise ValueError("Length must be greater than or equal to 43") + + if not length <= 128: + raise ValueError("Length must be less than or equal to 128") + + allowed_characters = re.compile('^[A-Zaa-z0-9-._~]') + code_verifier = secrets.token_urlsafe(length) + + if not re.search(allowed_characters, code_verifier): + raise ValueError("code_verifier contains invalid characters") + + self.code_verifier = code_verifier + + return code_verifier + + def create_code_challenge(self, code_verifier, code_challenge_method=None): + """Create PKCE **code_challenge** derived from the **code_verifier**. + + :param code_verifier: REQUIRED. The **code_verifier** generated from create_code_verifier(). + :param code_challenge_method: OPTIONAL. The method used to derive the **code_challenge**. Acceptable + values include "S256". DEFAULT is "plain". + + + The client then creates a code challenge derived from the code + verifier by using one of the following transformations on the code + verifier: + + plain + code_challenge = code_verifier + + S256 + code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + + If the client is capable of using "S256", it MUST use "S256", as + "S256" is Mandatory To Implement (MTI) on the server. Clients are + permitted to use "plain" only if they cannot support "S256" for some + technical reason and know via out-of-band configuration that the + server supports "plain". + + The plain transformation is for compatibility with existing + deployments and for constrained environments that can't use the S256 + transformation. + + .. _`Section 4.2`: https://tools.ietf.org/html/rfc7636#section-4.2 + """ + code_challenge = None + + if code_verifier == None: + raise ValueError("Invalid code_verifier") + + if code_challenge_method == None: + code_challenge_method = "plain" + self.code_challenge_method = code_challenge_method + code_challenge = code_verifier + self.code_challenge = code_challenge + + if code_challenge_method == "S256": + h = hashlib.sha256() + h.update(code_verifier.encode(encoding='ascii')) + sha256_val = h.digest() + code_challenge = bytes.decode(base64.urlsafe_b64encode(sha256_val)) + # replace '+' with '-', '/' with '_', and remove trailing '=' + code_challenge = code_challenge.replace("+", "-").replace("/", "_").replace("=", "") + self.code_challenge = code_challenge + + return code_challenge + def _add_mac_token(self, uri, http_method='GET', body=None, headers=None, token_placement=AUTH_HEADER, ext=None, **kwargs): """Add a MAC token to the request authorization header. @@ -513,7 +617,10 @@ class Client: self._expires_at = time.time() + int(self.expires_in) if 'expires_at' in response: - self._expires_at = int(response.get('expires_at')) + try: + self._expires_at = int(response.get('expires_at')) + except: + self._expires_at = None if 'mac_key' in response: self.mac_key = response.get('mac_key') diff --git a/lib/oauthlib/oauth2/rfc6749/clients/web_application.py b/lib/oauthlib/oauth2/rfc6749/clients/web_application.py index a1f3db1d..1d3b2b5b 100644 --- a/lib/oauthlib/oauth2/rfc6749/clients/web_application.py +++ b/lib/oauthlib/oauth2/rfc6749/clients/web_application.py @@ -41,7 +41,7 @@ class WebApplicationClient(Client): self.code = code def prepare_request_uri(self, uri, redirect_uri=None, scope=None, - state=None, **kwargs): + state=None, code_challenge=None, code_challenge_method='plain', **kwargs): """Prepare the authorization code request URI The client constructs the request URI by adding the following @@ -62,6 +62,13 @@ class WebApplicationClient(Client): to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in `Section 10.12`_. + :param code_challenge: OPTIONAL. PKCE parameter. REQUIRED if PKCE is enforced. + A challenge derived from the code_verifier that is sent in the + authorization request, to be verified against later. + + :param code_challenge_method: OPTIONAL. PKCE parameter. A method that was used to derive code challenge. + Defaults to "plain" if not present in the request. + :param kwargs: Extra arguments to include in the request URI. In addition to supplied parameters, OAuthLib will append the ``client_id`` @@ -76,6 +83,10 @@ class WebApplicationClient(Client): 'https://example.com?client_id=your_id&response_type=code&redirect_uri=https%3A%2F%2Fa.b%2Fcallback' >>> client.prepare_request_uri('https://example.com', scope=['profile', 'pictures']) 'https://example.com?client_id=your_id&response_type=code&scope=profile+pictures' + >>> client.prepare_request_uri('https://example.com', code_challenge='kjasBS523KdkAILD2k78NdcJSk2k3KHG6') + 'https://example.com?client_id=your_id&response_type=code&code_challenge=kjasBS523KdkAILD2k78NdcJSk2k3KHG6' + >>> client.prepare_request_uri('https://example.com', code_challenge_method='S256') + 'https://example.com?client_id=your_id&response_type=code&code_challenge_method=S256' >>> client.prepare_request_uri('https://example.com', foo='bar') 'https://example.com?client_id=your_id&response_type=code&foo=bar' @@ -87,10 +98,11 @@ class WebApplicationClient(Client): """ scope = self.scope if scope is None else scope return prepare_grant_uri(uri, self.client_id, 'code', - redirect_uri=redirect_uri, scope=scope, state=state, **kwargs) + redirect_uri=redirect_uri, scope=scope, state=state, code_challenge=code_challenge, + code_challenge_method=code_challenge_method, **kwargs) def prepare_request_body(self, code=None, redirect_uri=None, body='', - include_client_id=True, **kwargs): + include_client_id=True, code_verifier=None, **kwargs): """Prepare the access token request body. The client makes a request to the token endpoint by adding the @@ -113,6 +125,9 @@ class WebApplicationClient(Client): authorization server as described in `Section 3.2.1`_. :type include_client_id: Boolean + :param code_verifier: OPTIONAL. A cryptographically random string that is used to correlate the + authorization request to the token request. + :param kwargs: Extra parameters to include in the token request. In addition OAuthLib will add the ``grant_type`` parameter set to @@ -127,6 +142,8 @@ class WebApplicationClient(Client): >>> client = WebApplicationClient('your_id') >>> client.prepare_request_body(code='sh35ksdf09sf') 'grant_type=authorization_code&code=sh35ksdf09sf' + >>> client.prepare_request_body(code_verifier='KB46DCKJ873NCGXK5GD682NHDKK34GR') + 'grant_type=authorization_code&code_verifier=KB46DCKJ873NCGXK5GD682NHDKK34GR' >>> client.prepare_request_body(code='sh35ksdf09sf', foo='bar') 'grant_type=authorization_code&code=sh35ksdf09sf&foo=bar' @@ -154,7 +171,7 @@ class WebApplicationClient(Client): kwargs['client_id'] = self.client_id kwargs['include_client_id'] = include_client_id return prepare_token_request(self.grant_type, code=code, body=body, - redirect_uri=redirect_uri, **kwargs) + redirect_uri=redirect_uri, code_verifier=code_verifier, **kwargs) def parse_request_uri_response(self, uri, state=None): """Parse the URI query for code and state. diff --git a/lib/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/lib/oauthlib/oauth2/rfc6749/endpoints/metadata.py index 81ee1def..d43a8247 100644 --- a/lib/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/lib/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -54,7 +54,8 @@ class MetadataEndpoint(BaseEndpoint): """Create metadata response """ headers = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', } return headers, json.dumps(self.claims), 200 diff --git a/lib/oauthlib/oauth2/rfc6749/errors.py b/lib/oauthlib/oauth2/rfc6749/errors.py index b01e247b..da24feab 100644 --- a/lib/oauthlib/oauth2/rfc6749/errors.py +++ b/lib/oauthlib/oauth2/rfc6749/errors.py @@ -103,15 +103,12 @@ class OAuth2Error(Exception): value "Bearer". This scheme MUST be followed by one or more auth-param values. """ - authvalues = [ - "Bearer", - 'error="{}"'.format(self.error) - ] + authvalues = ['error="{}"'.format(self.error)] if self.description: authvalues.append('error_description="{}"'.format(self.description)) if self.uri: authvalues.append('error_uri="{}"'.format(self.uri)) - return {"WWW-Authenticate": ", ".join(authvalues)} + return {"WWW-Authenticate": "Bearer " + ", ".join(authvalues)} return {} diff --git a/lib/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/lib/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py index bf42d889..b799823e 100644 --- a/lib/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py +++ b/lib/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py @@ -10,6 +10,7 @@ import logging from oauthlib import common from .. import errors +from ..utils import is_secure_transport from .base import GrantTypeBase log = logging.getLogger(__name__) @@ -272,6 +273,8 @@ class AuthorizationCodeGrant(GrantTypeBase): grant = self.create_authorization_code(request) for modifier in self._code_modifiers: grant = modifier(grant, token_handler, request) + if 'access_token' in grant: + self.request_validator.save_token(grant, request) log.debug('Saving grant %r for %r.', grant, request) self.request_validator.save_authorization_code( request.client_id, grant, request) @@ -310,6 +313,7 @@ class AuthorizationCodeGrant(GrantTypeBase): self.request_validator.save_token(token, request) self.request_validator.invalidate_authorization_code( request.client_id, request.code, request) + headers.update(self._create_cors_headers(request)) return headers, json.dumps(token), 200 def validate_authorization_request(self, request): @@ -543,3 +547,20 @@ class AuthorizationCodeGrant(GrantTypeBase): if challenge_method in self._code_challenge_methods: return self._code_challenge_methods[challenge_method](verifier, challenge) raise NotImplementedError('Unknown challenge_method %s' % challenge_method) + + def _create_cors_headers(self, request): + """If CORS is allowed, create the appropriate headers.""" + if 'origin' not in request.headers: + return {} + + origin = request.headers['origin'] + if not is_secure_transport(origin): + log.debug('Origin "%s" is not HTTPS, CORS not allowed.', origin) + return {} + elif not self.request_validator.is_origin_allowed( + request.client_id, origin, request): + log.debug('Invalid origin "%s", CORS not allowed.', origin) + return {} + else: + log.debug('Valid origin "%s", injecting CORS headers.', origin) + return {'Access-Control-Allow-Origin': origin} diff --git a/lib/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py b/lib/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py index 8698a3d5..f801de4a 100644 --- a/lib/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py +++ b/lib/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py @@ -63,7 +63,7 @@ class RefreshTokenGrant(GrantTypeBase): refresh_token=self.issue_new_refresh_tokens) for modifier in self._token_modifiers: - token = modifier(token) + token = modifier(token, token_handler, request) self.request_validator.save_token(token, request) diff --git a/lib/oauthlib/oauth2/rfc6749/parameters.py b/lib/oauthlib/oauth2/rfc6749/parameters.py index f07b8bd2..44738bb4 100644 --- a/lib/oauthlib/oauth2/rfc6749/parameters.py +++ b/lib/oauthlib/oauth2/rfc6749/parameters.py @@ -23,7 +23,7 @@ from .utils import is_secure_transport, list_to_scope, scope_to_list def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, - scope=None, state=None, **kwargs): + scope=None, state=None, code_challenge=None, code_challenge_method='plain', **kwargs): """Prepare the authorization grant request URI. The client constructs the request URI by adding the following @@ -45,6 +45,11 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, back to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in `Section 10.12`_. + :param code_challenge: PKCE paramater. A challenge derived from the + code_verifier that is sent in the authorization + request, to be verified against later. + :param code_challenge_method: PKCE parameter. A method that was used to derive the + code_challenge. Defaults to "plain" if not present in the request. :param kwargs: Extra arguments to embed in the grant/authorization URL. An example of an authorization code grant authorization URL: @@ -52,6 +57,7 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, .. code-block:: http GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz + &code_challenge=kjasBS523KdkAILD2k78NdcJSk2k3KHG6&code_challenge_method=S256 &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 Host: server.example.com @@ -73,6 +79,9 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, params.append(('scope', list_to_scope(scope))) if state: params.append(('state', state)) + if code_challenge is not None: + params.append(('code_challenge', code_challenge)) + params.append(('code_challenge_method', code_challenge_method)) for k in kwargs: if kwargs[k]: @@ -81,7 +90,7 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, return add_params_to_uri(uri, params) -def prepare_token_request(grant_type, body='', include_client_id=True, **kwargs): +def prepare_token_request(grant_type, body='', include_client_id=True, code_verifier=None, **kwargs): """Prepare the access token request. The client makes a request to the token endpoint by adding the @@ -116,6 +125,9 @@ def prepare_token_request(grant_type, body='', include_client_id=True, **kwargs) authorization request as described in `Section 4.1.1`_, and their values MUST be identical. * + :param code_verifier: PKCE parameter. A cryptographically random string that is used to correlate the + authorization request to the token request. + :param kwargs: Extra arguments to embed in the request body. Parameters marked with a `*` above are not explicit arguments in the @@ -142,6 +154,10 @@ def prepare_token_request(grant_type, body='', include_client_id=True, **kwargs) if client_id is not None: params.append(('client_id', client_id)) + # use code_verifier if code_challenge was passed in the authorization request + if code_verifier is not None: + params.append(('code_verifier', code_verifier)) + # the kwargs iteration below only supports including boolean truth (truthy) # values, but some servers may require an empty string for `client_secret` client_secret = kwargs.pop('client_secret', None) diff --git a/lib/oauthlib/oauth2/rfc6749/request_validator.py b/lib/oauthlib/oauth2/rfc6749/request_validator.py index 817d594b..610a708d 100644 --- a/lib/oauthlib/oauth2/rfc6749/request_validator.py +++ b/lib/oauthlib/oauth2/rfc6749/request_validator.py @@ -649,3 +649,28 @@ class RequestValidator: """ raise NotImplementedError('Subclasses must implement this method.') + + def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): + """Indicate if the given origin is allowed to access the token endpoint + via Cross-Origin Resource Sharing (CORS). CORS is used by browser-based + clients, such as Single-Page Applications, to perform the Authorization + Code Grant. + + (Note: If performing Authorization Code Grant via a public client such + as a browser, you should use PKCE as well.) + + If this method returns true, the appropriate CORS headers will be added + to the response. By default this method always returns False, meaning + CORS is disabled. + + :param client_id: Unicode client identifier. + :param redirect_uri: Unicode origin. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: bool + + Method is used by: + - Authorization Code Grant + + """ + return False diff --git a/lib/oauthlib/oauth2/rfc8628/__init__.py b/lib/oauthlib/oauth2/rfc8628/__init__.py new file mode 100644 index 00000000..531929dc --- /dev/null +++ b/lib/oauthlib/oauth2/rfc8628/__init__.py @@ -0,0 +1,10 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 Device Authorization RFC8628. +""" +import logging + +log = logging.getLogger(__name__) diff --git a/lib/oauthlib/oauth2/rfc8628/clients/__init__.py b/lib/oauthlib/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 00000000..130b52e3 --- /dev/null +++ b/lib/oauthlib/oauth2/rfc8628/clients/__init__.py @@ -0,0 +1,8 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming OAuth 2.0 Device Authorization RFC8628. +""" +from .device import DeviceClient diff --git a/lib/oauthlib/oauth2/rfc8628/clients/device.py b/lib/oauthlib/oauth2/rfc8628/clients/device.py new file mode 100644 index 00000000..95c4f5a2 --- /dev/null +++ b/lib/oauthlib/oauth2/rfc8628/clients/device.py @@ -0,0 +1,94 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 Device Authorization RFC8628. +""" + +from oauthlib.oauth2 import BackendApplicationClient, Client +from oauthlib.oauth2.rfc6749.errors import InsecureTransportError +from oauthlib.oauth2.rfc6749.parameters import prepare_token_request +from oauthlib.oauth2.rfc6749.utils import is_secure_transport, list_to_scope +from oauthlib.common import add_params_to_uri + + +class DeviceClient(Client): + + """A public client utilizing the device authorization workflow. + + The client can request an access token using a device code and + a public client id associated with the device code as defined + in RFC8628. + + The device authorization grant type can be used to obtain both + access tokens and refresh tokens and is intended to be used in + a scenario where the device being authorized does not have a + user interface that is suitable for performing authentication. + """ + + grant_type = 'urn:ietf:params:oauth:grant-type:device_code' + + def __init__(self, client_id, **kwargs): + super().__init__(client_id, **kwargs) + self.client_secret = kwargs.get('client_secret') + + def prepare_request_uri(self, uri, scope=None, **kwargs): + if not is_secure_transport(uri): + raise InsecureTransportError() + + scope = self.scope if scope is None else scope + params = [(('client_id', self.client_id)), (('grant_type', self.grant_type))] + + if self.client_secret is not None: + params.append(('client_secret', self.client_secret)) + + if scope: + params.append(('scope', list_to_scope(scope))) + + for k in kwargs: + if kwargs[k]: + params.append((str(k), kwargs[k])) + + return add_params_to_uri(uri, params) + + def prepare_request_body(self, device_code, body='', scope=None, + include_client_id=False, **kwargs): + """Add device_code to request body + + The client makes a request to the token endpoint by adding the + device_code as a parameter using the + "application/x-www-form-urlencoded" format to the HTTP request + body. + + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra paramters. Default ''. + :param scope: The scope of the access request as described by + `Section 3.3`_. + + :param include_client_id: `True` to send the `client_id` in the + body of the upstream request. This is required + if the client is not authenticating with the + authorization server as described in + `Section 3.2.1`_. False otherwise (default). + :type include_client_id: Boolean + + :param kwargs: Extra credentials to include in the token request. + + The prepared body will include all provided device_code as well as + the ``grant_type`` parameter set to + ``urn:ietf:params:oauth:grant-type:device_code``:: + + >>> from oauthlib.oauth2 import DeviceClient + >>> client = DeviceClient('your_id', 'your_code') + >>> client.prepare_request_body(scope=['hello', 'world']) + 'grant_type=urn:ietf:params:oauth:grant-type:device_code&scope=hello+world' + + .. _`Section 3.4`: https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + """ + + kwargs['client_id'] = self.client_id + kwargs['include_client_id'] = include_client_id + scope = self.scope if scope is None else scope + return prepare_token_request(self.grant_type, body=body, device_code=device_code, + scope=scope, **kwargs) diff --git a/lib/oauthlib/openid/connect/core/grant_types/__init__.py b/lib/oauthlib/openid/connect/core/grant_types/__init__.py index 887a5850..8dad5f60 100644 --- a/lib/oauthlib/openid/connect/core/grant_types/__init__.py +++ b/lib/oauthlib/openid/connect/core/grant_types/__init__.py @@ -10,3 +10,4 @@ from .dispatchers import ( ) from .hybrid import HybridGrant from .implicit import ImplicitGrant +from .refresh_token import RefreshTokenGrant diff --git a/lib/oauthlib/openid/connect/core/grant_types/refresh_token.py b/lib/oauthlib/openid/connect/core/grant_types/refresh_token.py new file mode 100644 index 00000000..43e4499c --- /dev/null +++ b/lib/oauthlib/openid/connect/core/grant_types/refresh_token.py @@ -0,0 +1,34 @@ +""" +oauthlib.openid.connect.core.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +from oauthlib.oauth2.rfc6749.grant_types.refresh_token import ( + RefreshTokenGrant as OAuth2RefreshTokenGrant, +) + +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class RefreshTokenGrant(GrantTypeBase): + + def __init__(self, request_validator=None, **kwargs): + self.proxy_target = OAuth2RefreshTokenGrant( + request_validator=request_validator, **kwargs) + self.register_token_modifier(self.add_id_token) + + def add_id_token(self, token, token_handler, request): + """ + Construct an initial version of id_token, and let the + request_validator sign or encrypt it. + + The authorization_code version of this method is used to + retrieve the nonce accordingly to the code storage. + """ + if not self.request_validator.refresh_id_token(request): + return token + + return super().add_id_token(token, token_handler, request) diff --git a/lib/oauthlib/openid/connect/core/request_validator.py b/lib/oauthlib/openid/connect/core/request_validator.py index e8f334b0..47c4cd94 100644 --- a/lib/oauthlib/openid/connect/core/request_validator.py +++ b/lib/oauthlib/openid/connect/core/request_validator.py @@ -306,3 +306,15 @@ class RequestValidator(OAuth2RequestValidator): Method is used by: UserInfoEndpoint """ + + def refresh_id_token(self, request): + """Whether the id token should be refreshed. Default, True + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + RefreshTokenGrant + """ + return True diff --git a/lib/requests_oauthlib/__init__.py b/lib/requests_oauthlib/__init__.py index a4e03a4e..0d3e49f9 100644 --- a/lib/requests_oauthlib/__init__.py +++ b/lib/requests_oauthlib/__init__.py @@ -5,7 +5,7 @@ from .oauth1_session import OAuth1Session from .oauth2_auth import OAuth2 from .oauth2_session import OAuth2Session, TokenUpdated -__version__ = "1.3.0" +__version__ = "1.3.1" import requests diff --git a/lib/requests_oauthlib/compliance_fixes/__init__.py b/lib/requests_oauthlib/compliance_fixes/__init__.py index 02fa5120..0e8e3ac8 100644 --- a/lib/requests_oauthlib/compliance_fixes/__init__.py +++ b/lib/requests_oauthlib/compliance_fixes/__init__.py @@ -2,9 +2,9 @@ from __future__ import absolute_import from .facebook import facebook_compliance_fix from .fitbit import fitbit_compliance_fix -from .linkedin import linkedin_compliance_fix from .slack import slack_compliance_fix from .instagram import instagram_compliance_fix from .mailchimp import mailchimp_compliance_fix from .weibo import weibo_compliance_fix from .plentymarkets import plentymarkets_compliance_fix +from .ebay import ebay_compliance_fix diff --git a/lib/requests_oauthlib/compliance_fixes/ebay.py b/lib/requests_oauthlib/compliance_fixes/ebay.py new file mode 100644 index 00000000..4aa423b3 --- /dev/null +++ b/lib/requests_oauthlib/compliance_fixes/ebay.py @@ -0,0 +1,23 @@ +import json +from oauthlib.common import to_unicode + + +def ebay_compliance_fix(session): + def _compliance_fix(response): + token = json.loads(response.text) + + # eBay responds with non-compliant token types. + # https://developer.ebay.com/api-docs/static/oauth-client-credentials-grant.html + # https://developer.ebay.com/api-docs/static/oauth-auth-code-grant-request.html + # Modify these to be "Bearer". + if token.get("token_type") in ["Application Access Token", "User Access Token"]: + token["token_type"] = "Bearer" + fixed_token = json.dumps(token) + response._content = to_unicode(fixed_token).encode("utf-8") + + return response + + session.register_compliance_hook("access_token_response", _compliance_fix) + session.register_compliance_hook("refresh_token_response", _compliance_fix) + + return session diff --git a/lib/requests_oauthlib/compliance_fixes/linkedin.py b/lib/requests_oauthlib/compliance_fixes/linkedin.py deleted file mode 100644 index cd5b4ace..00000000 --- a/lib/requests_oauthlib/compliance_fixes/linkedin.py +++ /dev/null @@ -1,21 +0,0 @@ -from json import loads, dumps - -from oauthlib.common import add_params_to_uri, to_unicode - - -def linkedin_compliance_fix(session): - def _missing_token_type(r): - token = loads(r.text) - token["token_type"] = "Bearer" - r._content = to_unicode(dumps(token)).encode("UTF-8") - return r - - def _non_compliant_param_name(url, headers, data): - token = [("oauth2_access_token", session.access_token)] - url = add_params_to_uri(url, token) - return url, headers, data - - session._client.default_token_placement = "query" - session.register_compliance_hook("access_token_response", _missing_token_type) - session.register_compliance_hook("protected_request", _non_compliant_param_name) - return session diff --git a/lib/requests_oauthlib/oauth1_session.py b/lib/requests_oauthlib/oauth1_session.py index aa17f28f..88f2853c 100644 --- a/lib/requests_oauthlib/oauth1_session.py +++ b/lib/requests_oauthlib/oauth1_session.py @@ -268,7 +268,7 @@ class OAuth1Session(requests.Session): :param url: The request token endpoint URL. :param realm: A list of realms to request access to. :param \*\*request_kwargs: Optional arguments passed to ''post'' - function in ''requests.Session'' + function in ''requests.Session'' :returns: The response in dict format. Note that a previously set callback_uri will be reset for your diff --git a/lib/requests_oauthlib/oauth2_session.py b/lib/requests_oauthlib/oauth2_session.py index eea4ac6f..db446808 100644 --- a/lib/requests_oauthlib/oauth2_session.py +++ b/lib/requests_oauthlib/oauth2_session.py @@ -189,6 +189,7 @@ class OAuth2Session(requests.Session): proxies=None, include_client_id=None, client_secret=None, + cert=None, **kwargs ): """Generic method for fetching an access token from the token endpoint. @@ -229,6 +230,10 @@ class OAuth2Session(requests.Session): `auth` tuple. If the value is `None`, it will be omitted from the request, however if the value is an empty string, an empty string will be sent. + :param cert: Client certificate to send for OAuth 2.0 Mutual-TLS Client + Authentication (draft-ietf-oauth-mtls). Can either be the + path of a file containing the private key and certificate or + a tuple of two filenames for certificate and key. :param kwargs: Extra parameters to include in the token request. :return: A token dict """ @@ -341,6 +346,7 @@ class OAuth2Session(requests.Session): auth=auth, verify=verify, proxies=proxies, + cert=cert, **request_kwargs ) diff --git a/requirements.txt b/requirements.txt index 6388dc9f..25a4ecf8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,6 @@ IPy==1.01 Mako==1.1.6 MarkupSafe==2.0.1 musicbrainzngs==0.7.1 -oauthlib==3.1.1 packaging==21.3 paho-mqtt==1.6.1 plexapi==4.9.1 @@ -36,7 +35,7 @@ python-dateutil==2.8.2 python-twitter==3.5 pytz==2021.3 requests==2.27.1 -requests-oauthlib==1.3.0 +requests-oauthlib==1.3.1 rumps==0.3.0; platform_system == "Darwin" simplejson==3.17.6 six==1.16.0