diff options
author | alexv-smirnov <alex@ydb.tech> | 2023-12-01 12:02:50 +0300 |
---|---|---|
committer | alexv-smirnov <alex@ydb.tech> | 2023-12-01 13:28:10 +0300 |
commit | 0e578a4c44d4abd539d9838347b9ebafaca41dfb (patch) | |
tree | a0c1969c37f818c830ebeff9c077eacf30be6ef8 /contrib/python/oauthlib | |
parent | 84f2d3d4cc985e63217cff149bd2e6d67ae6fe22 (diff) | |
download | ydb-0e578a4c44d4abd539d9838347b9ebafaca41dfb.tar.gz |
Change "ya.make"
Diffstat (limited to 'contrib/python/oauthlib')
144 files changed, 21261 insertions, 0 deletions
diff --git a/contrib/python/oauthlib/.dist-info/METADATA b/contrib/python/oauthlib/.dist-info/METADATA new file mode 100644 index 0000000000..5bb339b0bd --- /dev/null +++ b/contrib/python/oauthlib/.dist-info/METADATA @@ -0,0 +1,179 @@ +Metadata-Version: 2.1 +Name: oauthlib +Version: 3.2.2 +Summary: A generic, spec-compliant, thorough implementation of the OAuth request-signing logic +Home-page: https://github.com/oauthlib/oauthlib +Author: The OAuthlib Community +Author-email: idan@gazit.me +Maintainer: Ib Lundgren +Maintainer-email: ib.lundgren@gmail.com +License: BSD +Platform: any +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Web Environment +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: MacOS +Classifier: Operating System :: POSIX +Classifier: Operating System :: POSIX :: Linux +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: Implementation +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.6 +Description-Content-Type: text/x-rst +License-File: LICENSE +Provides-Extra: rsa +Requires-Dist: cryptography (>=3.0.0) ; extra == 'rsa' +Provides-Extra: signals +Requires-Dist: blinker (>=1.4.0) ; extra == 'signals' +Provides-Extra: signedtoken +Requires-Dist: cryptography (>=3.0.0) ; extra == 'signedtoken' +Requires-Dist: pyjwt (<3,>=2.0.0) ; extra == 'signedtoken' + +OAuthLib - Python Framework for OAuth1 & OAuth2 +=============================================== + +*A generic, spec-compliant, thorough implementation of the OAuth request-signing +logic for Python 3.6+.* + +.. image:: https://app.travis-ci.com/oauthlib/oauthlib.svg?branch=master + :target: https://app.travis-ci.com/oauthlib/oauthlib + :alt: Travis +.. image:: https://coveralls.io/repos/oauthlib/oauthlib/badge.svg?branch=master + :target: https://coveralls.io/r/oauthlib/oauthlib + :alt: Coveralls +.. image:: https://img.shields.io/pypi/pyversions/oauthlib.svg + :target: https://pypi.org/project/oauthlib/ + :alt: Download from PyPI +.. image:: https://img.shields.io/pypi/l/oauthlib.svg + :target: https://pypi.org/project/oauthlib/ + :alt: License +.. image:: https://app.fossa.io/api/projects/git%2Bgithub.com%2Foauthlib%2Foauthlib.svg?type=shield + :target: https://app.fossa.io/projects/git%2Bgithub.com%2Foauthlib%2Foauthlib?ref=badge_shield + :alt: FOSSA Status +.. image:: https://img.shields.io/readthedocs/oauthlib.svg + :target: https://oauthlib.readthedocs.io/en/latest/index.html + :alt: Read the Docs +.. image:: https://badges.gitter.im/oauthlib/oauthlib.svg + :target: https://gitter.im/oauthlib/Lobby + :alt: Chat on Gitter + + +.. image:: https://raw.githubusercontent.com/oauthlib/oauthlib/8d71b161fd145d11c40d55c9ab66ac134a303253/docs/logo/oauthlib-banner-700x192.png + :target: https://github.com/oauthlib/oauthlib/ + :alt: OAuth + Python = OAuthlib Python Framework + + +OAuth often seems complicated and difficult-to-implement. There are several +prominent libraries for handling OAuth requests, but they all suffer from one or +both of the following: + +1. They predate the `OAuth 1.0 spec`_, AKA RFC 5849. +2. They predate the `OAuth 2.0 spec`_, AKA RFC 6749. +3. They assume the usage of a specific HTTP request library. + +.. _`OAuth 1.0 spec`: https://tools.ietf.org/html/rfc5849 +.. _`OAuth 2.0 spec`: https://tools.ietf.org/html/rfc6749 + +OAuthLib is a framework which implements the logic of OAuth1 or OAuth2 without +assuming a specific HTTP request object or web framework. Use it to graft OAuth +client support onto your favorite HTTP library, or provide support onto your +favourite web framework. If you're a maintainer of such a library, write a thin +veneer on top of OAuthLib and get OAuth support for very little effort. + + +Documentation +-------------- + +Full documentation is available on `Read the Docs`_. All contributions are very +welcome! The documentation is still quite sparse, please open an issue for what +you'd like to know, or discuss it in our `Gitter community`_, or even better, send a +pull request! + +.. _`Gitter community`: https://gitter.im/oauthlib/Lobby +.. _`Read the Docs`: https://oauthlib.readthedocs.io/en/latest/index.html + +Interested in making OAuth requests? +------------------------------------ + +Then you might be more interested in using `requests`_ which has OAuthLib +powered OAuth support provided by the `requests-oauthlib`_ library. + +.. _`requests`: https://github.com/requests/requests +.. _`requests-oauthlib`: https://github.com/requests/requests-oauthlib + +Which web frameworks are supported? +----------------------------------- + +The following packages provide OAuth support using OAuthLib. + +- For Django there is `django-oauth-toolkit`_, which includes `Django REST framework`_ support. +- For Flask there is `flask-oauthlib`_ and `Flask-Dance`_. +- For Pyramid there is `pyramid-oauthlib`_. +- For Bottle there is `bottle-oauthlib`_. + +If you have written an OAuthLib package that supports your favorite framework, +please open a Pull Request, updating the documentation. + +.. _`django-oauth-toolkit`: https://github.com/evonove/django-oauth-toolkit +.. _`flask-oauthlib`: https://github.com/lepture/flask-oauthlib +.. _`Django REST framework`: http://django-rest-framework.org +.. _`Flask-Dance`: https://github.com/singingwolfboy/flask-dance +.. _`pyramid-oauthlib`: https://github.com/tilgovi/pyramid-oauthlib +.. _`bottle-oauthlib`: https://github.com/thomsonreuters/bottle-oauthlib + +Using OAuthLib? Please get in touch! +------------------------------------ +Patching OAuth support onto an http request framework? Creating an OAuth +provider extension for a web framework? Simply using OAuthLib to Get Things Done +or to learn? + +No matter which we'd love to hear from you in our `Gitter community`_ or if you have +anything in particular you would like to have, change or comment on don't +hesitate for a second to send a pull request or open an issue. We might be quite +busy and therefore slow to reply but we love feedback! + +Chances are you have run into something annoying that you wish there was +documentation for, if you wish to gain eternal fame and glory, and a drink if we +have the pleasure to run into each other, please send a docs pull request =) + +.. _`Gitter community`: https://gitter.im/oauthlib/Lobby + +License +------- + +OAuthLib is yours to use and abuse according to the terms of the BSD license. +Check the LICENSE file for full details. + +Credits +------- + +OAuthLib has been started and maintained several years by Idan Gazit and other +amazing `AUTHORS`_. Thanks to their wonderful work, the open-source `community`_ +creation has been possible and the project can stay active and reactive to users +requests. + + +.. _`AUTHORS`: https://github.com/oauthlib/oauthlib/blob/master/AUTHORS +.. _`community`: https://github.com/oauthlib/ + +Changelog +--------- + +*OAuthLib is in active development, with the core of both OAuth1 and OAuth2 +completed, for providers as well as clients.* See `supported features`_ for +details. + +.. _`supported features`: https://oauthlib.readthedocs.io/en/latest/feature_matrix.html + +For a full changelog see ``CHANGELOG.rst``. diff --git a/contrib/python/oauthlib/.dist-info/top_level.txt b/contrib/python/oauthlib/.dist-info/top_level.txt new file mode 100644 index 0000000000..b5f3f0e345 --- /dev/null +++ b/contrib/python/oauthlib/.dist-info/top_level.txt @@ -0,0 +1 @@ +oauthlib diff --git a/contrib/python/oauthlib/LICENSE b/contrib/python/oauthlib/LICENSE new file mode 100644 index 0000000000..d5a9e9acd0 --- /dev/null +++ b/contrib/python/oauthlib/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2019 The OAuthlib Community +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of this project nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/contrib/python/oauthlib/README.rst b/contrib/python/oauthlib/README.rst new file mode 100644 index 0000000000..eb8c452d00 --- /dev/null +++ b/contrib/python/oauthlib/README.rst @@ -0,0 +1,137 @@ +OAuthLib - Python Framework for OAuth1 & OAuth2 +=============================================== + +*A generic, spec-compliant, thorough implementation of the OAuth request-signing +logic for Python 3.6+.* + +.. image:: https://app.travis-ci.com/oauthlib/oauthlib.svg?branch=master + :target: https://app.travis-ci.com/oauthlib/oauthlib + :alt: Travis +.. image:: https://coveralls.io/repos/oauthlib/oauthlib/badge.svg?branch=master + :target: https://coveralls.io/r/oauthlib/oauthlib + :alt: Coveralls +.. image:: https://img.shields.io/pypi/pyversions/oauthlib.svg + :target: https://pypi.org/project/oauthlib/ + :alt: Download from PyPI +.. image:: https://img.shields.io/pypi/l/oauthlib.svg + :target: https://pypi.org/project/oauthlib/ + :alt: License +.. image:: https://app.fossa.io/api/projects/git%2Bgithub.com%2Foauthlib%2Foauthlib.svg?type=shield + :target: https://app.fossa.io/projects/git%2Bgithub.com%2Foauthlib%2Foauthlib?ref=badge_shield + :alt: FOSSA Status +.. image:: https://img.shields.io/readthedocs/oauthlib.svg + :target: https://oauthlib.readthedocs.io/en/latest/index.html + :alt: Read the Docs +.. image:: https://badges.gitter.im/oauthlib/oauthlib.svg + :target: https://gitter.im/oauthlib/Lobby + :alt: Chat on Gitter + + +.. image:: https://raw.githubusercontent.com/oauthlib/oauthlib/8d71b161fd145d11c40d55c9ab66ac134a303253/docs/logo/oauthlib-banner-700x192.png + :target: https://github.com/oauthlib/oauthlib/ + :alt: OAuth + Python = OAuthlib Python Framework + + +OAuth often seems complicated and difficult-to-implement. There are several +prominent libraries for handling OAuth requests, but they all suffer from one or +both of the following: + +1. They predate the `OAuth 1.0 spec`_, AKA RFC 5849. +2. They predate the `OAuth 2.0 spec`_, AKA RFC 6749. +3. They assume the usage of a specific HTTP request library. + +.. _`OAuth 1.0 spec`: https://tools.ietf.org/html/rfc5849 +.. _`OAuth 2.0 spec`: https://tools.ietf.org/html/rfc6749 + +OAuthLib is a framework which implements the logic of OAuth1 or OAuth2 without +assuming a specific HTTP request object or web framework. Use it to graft OAuth +client support onto your favorite HTTP library, or provide support onto your +favourite web framework. If you're a maintainer of such a library, write a thin +veneer on top of OAuthLib and get OAuth support for very little effort. + + +Documentation +-------------- + +Full documentation is available on `Read the Docs`_. All contributions are very +welcome! The documentation is still quite sparse, please open an issue for what +you'd like to know, or discuss it in our `Gitter community`_, or even better, send a +pull request! + +.. _`Gitter community`: https://gitter.im/oauthlib/Lobby +.. _`Read the Docs`: https://oauthlib.readthedocs.io/en/latest/index.html + +Interested in making OAuth requests? +------------------------------------ + +Then you might be more interested in using `requests`_ which has OAuthLib +powered OAuth support provided by the `requests-oauthlib`_ library. + +.. _`requests`: https://github.com/requests/requests +.. _`requests-oauthlib`: https://github.com/requests/requests-oauthlib + +Which web frameworks are supported? +----------------------------------- + +The following packages provide OAuth support using OAuthLib. + +- For Django there is `django-oauth-toolkit`_, which includes `Django REST framework`_ support. +- For Flask there is `flask-oauthlib`_ and `Flask-Dance`_. +- For Pyramid there is `pyramid-oauthlib`_. +- For Bottle there is `bottle-oauthlib`_. + +If you have written an OAuthLib package that supports your favorite framework, +please open a Pull Request, updating the documentation. + +.. _`django-oauth-toolkit`: https://github.com/evonove/django-oauth-toolkit +.. _`flask-oauthlib`: https://github.com/lepture/flask-oauthlib +.. _`Django REST framework`: http://django-rest-framework.org +.. _`Flask-Dance`: https://github.com/singingwolfboy/flask-dance +.. _`pyramid-oauthlib`: https://github.com/tilgovi/pyramid-oauthlib +.. _`bottle-oauthlib`: https://github.com/thomsonreuters/bottle-oauthlib + +Using OAuthLib? Please get in touch! +------------------------------------ +Patching OAuth support onto an http request framework? Creating an OAuth +provider extension for a web framework? Simply using OAuthLib to Get Things Done +or to learn? + +No matter which we'd love to hear from you in our `Gitter community`_ or if you have +anything in particular you would like to have, change or comment on don't +hesitate for a second to send a pull request or open an issue. We might be quite +busy and therefore slow to reply but we love feedback! + +Chances are you have run into something annoying that you wish there was +documentation for, if you wish to gain eternal fame and glory, and a drink if we +have the pleasure to run into each other, please send a docs pull request =) + +.. _`Gitter community`: https://gitter.im/oauthlib/Lobby + +License +------- + +OAuthLib is yours to use and abuse according to the terms of the BSD license. +Check the LICENSE file for full details. + +Credits +------- + +OAuthLib has been started and maintained several years by Idan Gazit and other +amazing `AUTHORS`_. Thanks to their wonderful work, the open-source `community`_ +creation has been possible and the project can stay active and reactive to users +requests. + + +.. _`AUTHORS`: https://github.com/oauthlib/oauthlib/blob/master/AUTHORS +.. _`community`: https://github.com/oauthlib/ + +Changelog +--------- + +*OAuthLib is in active development, with the core of both OAuth1 and OAuth2 +completed, for providers as well as clients.* See `supported features`_ for +details. + +.. _`supported features`: https://oauthlib.readthedocs.io/en/latest/feature_matrix.html + +For a full changelog see ``CHANGELOG.rst``. diff --git a/contrib/python/oauthlib/oauthlib/__init__.py b/contrib/python/oauthlib/oauthlib/__init__.py new file mode 100644 index 0000000000..d9a5e38ea0 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/__init__.py @@ -0,0 +1,34 @@ +""" + oauthlib + ~~~~~~~~ + + A generic, spec-compliant, thorough implementation of the OAuth + request-signing logic. + + :copyright: (c) 2019 by The OAuthlib Community + :license: BSD, see LICENSE for details. +""" +import logging +from logging import NullHandler + +__author__ = 'The OAuthlib Community' +__version__ = '3.2.2' + +logging.getLogger('oauthlib').addHandler(NullHandler()) + +_DEBUG = False + +def set_debug(debug_val): + """Set value of debug flag + + :param debug_val: Value to set. Must be a bool value. + """ + global _DEBUG + _DEBUG = debug_val + +def get_debug(): + """Get debug mode value. + + :return: `True` if debug mode is on, `False` otherwise + """ + return _DEBUG diff --git a/contrib/python/oauthlib/oauthlib/common.py b/contrib/python/oauthlib/oauthlib/common.py new file mode 100644 index 0000000000..395e75efc9 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/common.py @@ -0,0 +1,432 @@ +""" +oauthlib.common +~~~~~~~~~~~~~~ + +This module provides data structures and utilities common +to all implementations of OAuth. +""" +import collections +import datetime +import logging +import re +import time +import urllib.parse as urlparse +from urllib.parse import ( + quote as _quote, unquote as _unquote, urlencode as _urlencode, +) + +from . import get_debug + +try: + from secrets import SystemRandom, randbits +except ImportError: + from random import SystemRandom, getrandbits as randbits + +UNICODE_ASCII_CHARACTER_SET = ('abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '0123456789') + +CLIENT_ID_CHARACTER_SET = (r' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMN' + 'OPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}') + +SANITIZE_PATTERN = re.compile(r'([^&;]*(?:password|token)[^=]*=)[^&;]+', re.IGNORECASE) +INVALID_HEX_PATTERN = re.compile(r'%[^0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]') + +always_safe = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789' '_.-') + +log = logging.getLogger('oauthlib') + + +# 'safe' must be bytes (Python 2.6 requires bytes, other versions allow either) +def quote(s, safe=b'/'): + s = s.encode('utf-8') if isinstance(s, str) else s + s = _quote(s, safe) + # PY3 always returns unicode. PY2 may return either, depending on whether + # it had to modify the string. + if isinstance(s, bytes): + s = s.decode('utf-8') + return s + + +def unquote(s): + s = _unquote(s) + # PY3 always returns unicode. PY2 seems to always return what you give it, + # which differs from quote's behavior. Just to be safe, make sure it is + # unicode before we return. + if isinstance(s, bytes): + s = s.decode('utf-8') + return s + + +def urlencode(params): + utf8_params = encode_params_utf8(params) + urlencoded = _urlencode(utf8_params) + if isinstance(urlencoded, str): + return urlencoded + else: + return urlencoded.decode("utf-8") + + +def encode_params_utf8(params): + """Ensures that all parameters in a list of 2-element tuples are encoded to + bytestrings using UTF-8 + """ + encoded = [] + for k, v in params: + encoded.append(( + k.encode('utf-8') if isinstance(k, str) else k, + v.encode('utf-8') if isinstance(v, str) else v)) + return encoded + + +def decode_params_utf8(params): + """Ensures that all parameters in a list of 2-element tuples are decoded to + unicode using UTF-8. + """ + decoded = [] + for k, v in params: + decoded.append(( + k.decode('utf-8') if isinstance(k, bytes) else k, + v.decode('utf-8') if isinstance(v, bytes) else v)) + return decoded + + +urlencoded = set(always_safe) | set('=&;:%+~,*@!()/?\'$') + + +def urldecode(query): + """Decode a query string in x-www-form-urlencoded format into a sequence + of two-element tuples. + + Unlike urlparse.parse_qsl(..., strict_parsing=True) urldecode will enforce + correct formatting of the query string by validation. If validation fails + a ValueError will be raised. urllib.parse_qsl will only raise errors if + any of name-value pairs omits the equals sign. + """ + # Check if query contains invalid characters + if query and not set(query) <= urlencoded: + error = ("Error trying to decode a non urlencoded string. " + "Found invalid characters: %s " + "in the string: '%s'. " + "Please ensure the request/response body is " + "x-www-form-urlencoded.") + raise ValueError(error % (set(query) - urlencoded, query)) + + # Check for correctly hex encoded values using a regular expression + # All encoded values begin with % followed by two hex characters + # correct = %00, %A0, %0A, %FF + # invalid = %G0, %5H, %PO + if INVALID_HEX_PATTERN.search(query): + raise ValueError('Invalid hex encoding in query string.') + + # We want to allow queries such as "c2" whereas urlparse.parse_qsl + # with the strict_parsing flag will not. + params = urlparse.parse_qsl(query, keep_blank_values=True) + + # unicode all the things + return decode_params_utf8(params) + + +def extract_params(raw): + """Extract parameters and return them as a list of 2-tuples. + + Will successfully extract parameters from urlencoded query strings, + dicts, or lists of 2-tuples. Empty strings/dicts/lists will return an + empty list of parameters. Any other input will result in a return + value of None. + """ + if isinstance(raw, (bytes, str)): + try: + params = urldecode(raw) + except ValueError: + params = None + elif hasattr(raw, '__iter__'): + try: + dict(raw) + except ValueError: + params = None + except TypeError: + params = None + else: + params = list(raw.items() if isinstance(raw, dict) else raw) + params = decode_params_utf8(params) + else: + params = None + + return params + + +def generate_nonce(): + """Generate pseudorandom nonce that is unlikely to repeat. + + Per `section 3.3`_ of the OAuth 1 RFC 5849 spec. + Per `section 3.2.1`_ of the MAC Access Authentication spec. + + A random 64-bit number is appended to the epoch timestamp for both + randomness and to decrease the likelihood of collisions. + + .. _`section 3.2.1`: https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-3.2.1 + .. _`section 3.3`: https://tools.ietf.org/html/rfc5849#section-3.3 + """ + return str(str(randbits(64)) + generate_timestamp()) + + +def generate_timestamp(): + """Get seconds since epoch (UTC). + + Per `section 3.3`_ of the OAuth 1 RFC 5849 spec. + Per `section 3.2.1`_ of the MAC Access Authentication spec. + + .. _`section 3.2.1`: https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-3.2.1 + .. _`section 3.3`: https://tools.ietf.org/html/rfc5849#section-3.3 + """ + return str(int(time.time())) + + +def generate_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET): + """Generates a non-guessable OAuth token + + OAuth (1 and 2) does not specify the format of tokens except that they + should be strings of random characters. Tokens should not be guessable + and entropy when generating the random characters is important. Which is + why SystemRandom is used instead of the default random.choice method. + """ + rand = SystemRandom() + return ''.join(rand.choice(chars) for x in range(length)) + + +def generate_signed_token(private_pem, request): + import jwt + + now = datetime.datetime.utcnow() + + claims = { + 'scope': request.scope, + 'exp': now + datetime.timedelta(seconds=request.expires_in) + } + + claims.update(request.claims) + + token = jwt.encode(claims, private_pem, 'RS256') + token = to_unicode(token, "UTF-8") + + return token + + +def verify_signed_token(public_pem, token): + import jwt + + return jwt.decode(token, public_pem, algorithms=['RS256']) + + +def generate_client_id(length=30, chars=CLIENT_ID_CHARACTER_SET): + """Generates an OAuth client_id + + OAuth 2 specify the format of client_id in + https://tools.ietf.org/html/rfc6749#appendix-A. + """ + return generate_token(length, chars) + + +def add_params_to_qs(query, params): + """Extend a query with a list of two-tuples.""" + if isinstance(params, dict): + params = params.items() + queryparams = urlparse.parse_qsl(query, keep_blank_values=True) + queryparams.extend(params) + return urlencode(queryparams) + + +def add_params_to_uri(uri, params, fragment=False): + """Add a list of two-tuples to the uri query components.""" + sch, net, path, par, query, fra = urlparse.urlparse(uri) + if fragment: + fra = add_params_to_qs(fra, params) + else: + query = add_params_to_qs(query, params) + return urlparse.urlunparse((sch, net, path, par, query, fra)) + + +def safe_string_equals(a, b): + """ Near-constant time string comparison. + + Used in order to avoid timing attacks on sensitive information such + as secret keys during request verification (`rootLabs`_). + + .. _`rootLabs`: http://rdist.root.org/2010/01/07/timing-independent-array-comparison/ + + """ + if len(a) != len(b): + return False + + result = 0 + for x, y in zip(a, b): + result |= ord(x) ^ ord(y) + return result == 0 + + +def to_unicode(data, encoding='UTF-8'): + """Convert a number of different types of objects to unicode.""" + if isinstance(data, str): + return data + + if isinstance(data, bytes): + return str(data, encoding=encoding) + + if hasattr(data, '__iter__'): + try: + dict(data) + except TypeError: + pass + except ValueError: + # Assume it's a one dimensional data structure + return (to_unicode(i, encoding) for i in data) + else: + # We support 2.6 which lacks dict comprehensions + if hasattr(data, 'items'): + data = data.items() + return {to_unicode(k, encoding): to_unicode(v, encoding) for k, v in data} + + return data + + +class CaseInsensitiveDict(dict): + + """Basic case insensitive dict with strings only keys.""" + + proxy = {} + + def __init__(self, data): + self.proxy = {k.lower(): k for k in data} + for k in data: + self[k] = data[k] + + def __contains__(self, k): + return k.lower() in self.proxy + + def __delitem__(self, k): + key = self.proxy[k.lower()] + super().__delitem__(key) + del self.proxy[k.lower()] + + def __getitem__(self, k): + key = self.proxy[k.lower()] + return super().__getitem__(key) + + def get(self, k, default=None): + return self[k] if k in self else default + + def __setitem__(self, k, v): + super().__setitem__(k, v) + self.proxy[k.lower()] = k + + def update(self, *args, **kwargs): + super().update(*args, **kwargs) + for k in dict(*args, **kwargs): + self.proxy[k.lower()] = k + + +class Request: + + """A malleable representation of a signable HTTP request. + + Body argument may contain any data, but parameters will only be decoded if + they are one of: + + * urlencoded query string + * dict + * list of 2-tuples + + Anything else will be treated as raw body data to be passed through + unmolested. + """ + + def __init__(self, uri, http_method='GET', body=None, headers=None, + encoding='utf-8'): + # Convert to unicode using encoding if given, else assume unicode + encode = lambda x: to_unicode(x, encoding) if encoding else x + + self.uri = encode(uri) + self.http_method = encode(http_method) + self.headers = CaseInsensitiveDict(encode(headers or {})) + self.body = encode(body) + self.decoded_body = extract_params(self.body) + self.oauth_params = [] + self.validator_log = {} + + self._params = { + "access_token": None, + "client": None, + "client_id": None, + "client_secret": None, + "code": None, + "code_challenge": None, + "code_challenge_method": None, + "code_verifier": None, + "extra_credentials": None, + "grant_type": None, + "redirect_uri": None, + "refresh_token": None, + "request_token": None, + "response_type": None, + "scope": None, + "scopes": None, + "state": None, + "token": None, + "user": None, + "token_type_hint": None, + + # OpenID Connect + "response_mode": None, + "nonce": None, + "display": None, + "prompt": None, + "claims": None, + "max_age": None, + "ui_locales": None, + "id_token_hint": None, + "login_hint": None, + "acr_values": None + } + self._params.update(dict(urldecode(self.uri_query))) + self._params.update(dict(self.decoded_body or [])) + + def __getattr__(self, name): + if name in self._params: + return self._params[name] + else: + raise AttributeError(name) + + def __repr__(self): + if not get_debug(): + return "<oauthlib.Request SANITIZED>" + body = self.body + headers = self.headers.copy() + if body: + body = SANITIZE_PATTERN.sub('\1<SANITIZED>', str(body)) + if 'Authorization' in headers: + headers['Authorization'] = '<SANITIZED>' + return '<oauthlib.Request url="{}", http_method="{}", headers="{}", body="{}">'.format( + self.uri, self.http_method, headers, body) + + @property + def uri_query(self): + return urlparse.urlparse(self.uri).query + + @property + def uri_query_params(self): + if not self.uri_query: + return [] + return urlparse.parse_qsl(self.uri_query, keep_blank_values=True, + strict_parsing=True) + + @property + def duplicate_params(self): + seen_keys = collections.defaultdict(int) + all_keys = (p[0] + for p in (self.decoded_body or []) + self.uri_query_params) + for k in all_keys: + seen_keys[k] += 1 + return [k for k, c in seen_keys.items() if c > 1] diff --git a/contrib/python/oauthlib/oauthlib/oauth1/__init__.py b/contrib/python/oauthlib/oauthlib/oauth1/__init__.py new file mode 100644 index 0000000000..9caf12a90d --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/__init__.py @@ -0,0 +1,23 @@ +""" +oauthlib.oauth1 +~~~~~~~~~~~~~~ + +This module is a wrapper for the most recent implementation of OAuth 1.0 Client +and Server classes. +""" +from .rfc5849 import ( + SIGNATURE_HMAC, SIGNATURE_HMAC_SHA1, SIGNATURE_HMAC_SHA256, + SIGNATURE_HMAC_SHA512, SIGNATURE_PLAINTEXT, SIGNATURE_RSA, + SIGNATURE_RSA_SHA1, SIGNATURE_RSA_SHA256, SIGNATURE_RSA_SHA512, + SIGNATURE_TYPE_AUTH_HEADER, SIGNATURE_TYPE_BODY, SIGNATURE_TYPE_QUERY, + Client, +) +from .rfc5849.endpoints import ( + AccessTokenEndpoint, AuthorizationEndpoint, RequestTokenEndpoint, + ResourceEndpoint, SignatureOnlyEndpoint, WebApplicationServer, +) +from .rfc5849.errors import ( + InsecureTransportError, InvalidClientError, InvalidRequestError, + InvalidSignatureMethodError, OAuth1Error, +) +from .rfc5849.request_validator import RequestValidator diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/__init__.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/__init__.py new file mode 100644 index 0000000000..c559251fed --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/__init__.py @@ -0,0 +1,365 @@ +""" +oauthlib.oauth1.rfc5849 +~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. + +It supports all three standard signature methods defined in RFC 5849: + +- HMAC-SHA1 +- RSA-SHA1 +- PLAINTEXT + +It also supports signature methods that are not defined in RFC 5849. These are +based on the standard ones but replace SHA-1 with the more secure SHA-256: + +- HMAC-SHA256 +- RSA-SHA256 + +""" +import base64 +import hashlib +import logging +import urllib.parse as urlparse + +from oauthlib.common import ( + Request, generate_nonce, generate_timestamp, to_unicode, urlencode, +) + +from . import parameters, signature + +log = logging.getLogger(__name__) + +# Available signature methods +# +# Note: SIGNATURE_HMAC and SIGNATURE_RSA are kept for backward compatibility +# with previous versions of this library, when it the only HMAC-based and +# RSA-based signature methods were HMAC-SHA1 and RSA-SHA1. But now that it +# supports other hashing algorithms besides SHA1, explicitly identifying which +# hashing algorithm is being used is recommended. +# +# Note: if additional values are defined here, don't forget to update the +# imports in "../__init__.py" so they are available outside this module. + +SIGNATURE_HMAC_SHA1 = "HMAC-SHA1" +SIGNATURE_HMAC_SHA256 = "HMAC-SHA256" +SIGNATURE_HMAC_SHA512 = "HMAC-SHA512" +SIGNATURE_HMAC = SIGNATURE_HMAC_SHA1 # deprecated variable for HMAC-SHA1 + +SIGNATURE_RSA_SHA1 = "RSA-SHA1" +SIGNATURE_RSA_SHA256 = "RSA-SHA256" +SIGNATURE_RSA_SHA512 = "RSA-SHA512" +SIGNATURE_RSA = SIGNATURE_RSA_SHA1 # deprecated variable for RSA-SHA1 + +SIGNATURE_PLAINTEXT = "PLAINTEXT" + +SIGNATURE_METHODS = ( + SIGNATURE_HMAC_SHA1, + SIGNATURE_HMAC_SHA256, + SIGNATURE_HMAC_SHA512, + SIGNATURE_RSA_SHA1, + SIGNATURE_RSA_SHA256, + SIGNATURE_RSA_SHA512, + SIGNATURE_PLAINTEXT +) + +SIGNATURE_TYPE_AUTH_HEADER = 'AUTH_HEADER' +SIGNATURE_TYPE_QUERY = 'QUERY' +SIGNATURE_TYPE_BODY = 'BODY' + +CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded' + + +class Client: + + """A client used to sign OAuth 1.0 RFC 5849 requests.""" + SIGNATURE_METHODS = { + SIGNATURE_HMAC_SHA1: signature.sign_hmac_sha1_with_client, + SIGNATURE_HMAC_SHA256: signature.sign_hmac_sha256_with_client, + SIGNATURE_HMAC_SHA512: signature.sign_hmac_sha512_with_client, + SIGNATURE_RSA_SHA1: signature.sign_rsa_sha1_with_client, + SIGNATURE_RSA_SHA256: signature.sign_rsa_sha256_with_client, + SIGNATURE_RSA_SHA512: signature.sign_rsa_sha512_with_client, + SIGNATURE_PLAINTEXT: signature.sign_plaintext_with_client + } + + @classmethod + def register_signature_method(cls, method_name, method_callback): + cls.SIGNATURE_METHODS[method_name] = method_callback + + def __init__(self, client_key, + client_secret=None, + resource_owner_key=None, + resource_owner_secret=None, + callback_uri=None, + signature_method=SIGNATURE_HMAC_SHA1, + signature_type=SIGNATURE_TYPE_AUTH_HEADER, + rsa_key=None, verifier=None, realm=None, + encoding='utf-8', decoding=None, + nonce=None, timestamp=None): + """Create an OAuth 1 client. + + :param client_key: Client key (consumer key), mandatory. + :param resource_owner_key: Resource owner key (oauth token). + :param resource_owner_secret: Resource owner secret (oauth token secret). + :param callback_uri: Callback used when obtaining request token. + :param signature_method: SIGNATURE_HMAC, SIGNATURE_RSA or SIGNATURE_PLAINTEXT. + :param signature_type: SIGNATURE_TYPE_AUTH_HEADER (default), + SIGNATURE_TYPE_QUERY or SIGNATURE_TYPE_BODY + depending on where you want to embed the oauth + credentials. + :param rsa_key: RSA key used with SIGNATURE_RSA. + :param verifier: Verifier used when obtaining an access token. + :param realm: Realm (scope) to which access is being requested. + :param encoding: If you provide non-unicode input you may use this + to have oauthlib automatically convert. + :param decoding: If you wish that the returned uri, headers and body + from sign be encoded back from unicode, then set + decoding to your preferred encoding, i.e. utf-8. + :param nonce: Use this nonce instead of generating one. (Mainly for testing) + :param timestamp: Use this timestamp instead of using current. (Mainly for testing) + """ + # Convert to unicode using encoding if given, else assume unicode + encode = lambda x: to_unicode(x, encoding) if encoding else x + + self.client_key = encode(client_key) + self.client_secret = encode(client_secret) + self.resource_owner_key = encode(resource_owner_key) + self.resource_owner_secret = encode(resource_owner_secret) + self.signature_method = encode(signature_method) + self.signature_type = encode(signature_type) + self.callback_uri = encode(callback_uri) + self.rsa_key = encode(rsa_key) + self.verifier = encode(verifier) + self.realm = encode(realm) + self.encoding = encode(encoding) + self.decoding = encode(decoding) + self.nonce = encode(nonce) + self.timestamp = encode(timestamp) + + def __repr__(self): + attrs = vars(self).copy() + attrs['client_secret'] = '****' if attrs['client_secret'] else None + attrs['rsa_key'] = '****' if attrs['rsa_key'] else None + attrs[ + 'resource_owner_secret'] = '****' if attrs['resource_owner_secret'] else None + attribute_str = ', '.join('{}={}'.format(k, v) for k, v in attrs.items()) + return '<{} {}>'.format(self.__class__.__name__, attribute_str) + + def get_oauth_signature(self, request): + """Get an OAuth signature to be used in signing a request + + To satisfy `section 3.4.1.2`_ item 2, if the request argument's + headers dict attribute contains a Host item, its value will + replace any netloc part of the request argument's uri attribute + value. + + .. _`section 3.4.1.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.2 + """ + if self.signature_method == SIGNATURE_PLAINTEXT: + # fast-path + return signature.sign_plaintext(self.client_secret, + self.resource_owner_secret) + + uri, headers, body = self._render(request) + + collected_params = signature.collect_parameters( + uri_query=urlparse.urlparse(uri).query, + body=body, + headers=headers) + log.debug("Collected params: {}".format(collected_params)) + + normalized_params = signature.normalize_parameters(collected_params) + normalized_uri = signature.base_string_uri(uri, headers.get('Host', None)) + log.debug("Normalized params: {}".format(normalized_params)) + log.debug("Normalized URI: {}".format(normalized_uri)) + + base_string = signature.signature_base_string(request.http_method, + normalized_uri, normalized_params) + + log.debug("Signing: signature base string: {}".format(base_string)) + + if self.signature_method not in self.SIGNATURE_METHODS: + raise ValueError('Invalid signature method.') + + sig = self.SIGNATURE_METHODS[self.signature_method](base_string, self) + + log.debug("Signature: {}".format(sig)) + return sig + + def get_oauth_params(self, request): + """Get the basic OAuth parameters to be used in generating a signature. + """ + nonce = (generate_nonce() + if self.nonce is None else self.nonce) + timestamp = (generate_timestamp() + if self.timestamp is None else self.timestamp) + params = [ + ('oauth_nonce', nonce), + ('oauth_timestamp', timestamp), + ('oauth_version', '1.0'), + ('oauth_signature_method', self.signature_method), + ('oauth_consumer_key', self.client_key), + ] + if self.resource_owner_key: + params.append(('oauth_token', self.resource_owner_key)) + if self.callback_uri: + params.append(('oauth_callback', self.callback_uri)) + if self.verifier: + params.append(('oauth_verifier', self.verifier)) + + # providing body hash for requests other than x-www-form-urlencoded + # as described in https://tools.ietf.org/html/draft-eaton-oauth-bodyhash-00#section-4.1.1 + # 4.1.1. When to include the body hash + # * [...] MUST NOT include an oauth_body_hash parameter on requests with form-encoded request bodies + # * [...] SHOULD include the oauth_body_hash parameter on all other requests. + # Note that SHA-1 is vulnerable. The spec acknowledges that in https://tools.ietf.org/html/draft-eaton-oauth-bodyhash-00#section-6.2 + # At this time, no further effort has been made to replace SHA-1 for the OAuth Request Body Hash extension. + content_type = request.headers.get('Content-Type', None) + content_type_eligible = content_type and content_type.find('application/x-www-form-urlencoded') < 0 + if request.body is not None and content_type_eligible: + params.append(('oauth_body_hash', base64.b64encode(hashlib.sha1(request.body.encode('utf-8')).digest()).decode('utf-8'))) + + return params + + def _render(self, request, formencode=False, realm=None): + """Render a signed request according to signature type + + Returns a 3-tuple containing the request URI, headers, and body. + + If the formencode argument is True and the body contains parameters, it + is escaped and returned as a valid formencoded string. + """ + # TODO what if there are body params on a header-type auth? + # TODO what if there are query params on a body-type auth? + + uri, headers, body = request.uri, request.headers, request.body + + # TODO: right now these prepare_* methods are very narrow in scope--they + # only affect their little thing. In some cases (for example, with + # header auth) it might be advantageous to allow these methods to touch + # other parts of the request, like the headers—so the prepare_headers + # method could also set the Content-Type header to x-www-form-urlencoded + # like the spec requires. This would be a fundamental change though, and + # I'm not sure how I feel about it. + if self.signature_type == SIGNATURE_TYPE_AUTH_HEADER: + headers = parameters.prepare_headers( + request.oauth_params, request.headers, realm=realm) + elif self.signature_type == SIGNATURE_TYPE_BODY and request.decoded_body is not None: + body = parameters.prepare_form_encoded_body( + request.oauth_params, request.decoded_body) + if formencode: + body = urlencode(body) + headers['Content-Type'] = 'application/x-www-form-urlencoded' + elif self.signature_type == SIGNATURE_TYPE_QUERY: + uri = parameters.prepare_request_uri_query( + request.oauth_params, request.uri) + else: + raise ValueError('Unknown signature type specified.') + + return uri, headers, body + + def sign(self, uri, http_method='GET', body=None, headers=None, realm=None): + """Sign a request + + Signs an HTTP request with the specified parts. + + Returns a 3-tuple of the signed request's URI, headers, and body. + Note that http_method is not returned as it is unaffected by the OAuth + signing process. Also worth noting is that duplicate parameters + will be included in the signature, regardless of where they are + specified (query, body). + + The body argument may be a dict, a list of 2-tuples, or a formencoded + string. The Content-Type header must be 'application/x-www-form-urlencoded' + if it is present. + + If the body argument is not one of the above, it will be returned + verbatim as it is unaffected by the OAuth signing process. Attempting to + sign a request with non-formencoded data using the OAuth body signature + type is invalid and will raise an exception. + + If the body does contain parameters, it will be returned as a properly- + formatted formencoded string. + + Body may not be included if the http_method is either GET or HEAD as + this changes the semantic meaning of the request. + + All string data MUST be unicode or be encoded with the same encoding + scheme supplied to the Client constructor, default utf-8. This includes + strings inside body dicts, for example. + """ + # normalize request data + request = Request(uri, http_method, body, headers, + encoding=self.encoding) + + # sanity check + content_type = request.headers.get('Content-Type', None) + multipart = content_type and content_type.startswith('multipart/') + should_have_params = content_type == CONTENT_TYPE_FORM_URLENCODED + has_params = request.decoded_body is not None + # 3.4.1.3.1. Parameter Sources + # [Parameters are collected from the HTTP request entity-body, but only + # if [...]: + # * The entity-body is single-part. + if multipart and has_params: + raise ValueError( + "Headers indicate a multipart body but body contains parameters.") + # * The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # [W3C.REC-html40-19980424]. + elif should_have_params and not has_params: + raise ValueError( + "Headers indicate a formencoded body but body was not decodable.") + # * The HTTP request entity-header includes the "Content-Type" + # header field set to "application/x-www-form-urlencoded". + elif not should_have_params and has_params: + raise ValueError( + "Body contains parameters but Content-Type header was {} " + "instead of {}".format(content_type or "not set", + CONTENT_TYPE_FORM_URLENCODED)) + + # 3.5.2. Form-Encoded Body + # Protocol parameters can be transmitted in the HTTP request entity- + # body, but only if the following REQUIRED conditions are met: + # o The entity-body is single-part. + # o The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # [W3C.REC-html40-19980424]. + # o The HTTP request entity-header includes the "Content-Type" header + # field set to "application/x-www-form-urlencoded". + elif self.signature_type == SIGNATURE_TYPE_BODY and not ( + should_have_params and has_params and not multipart): + raise ValueError( + 'Body signatures may only be used with form-urlencoded content') + + # We amend https://tools.ietf.org/html/rfc5849#section-3.4.1.3.1 + # with the clause that parameters from body should only be included + # in non GET or HEAD requests. Extracting the request body parameters + # and including them in the signature base string would give semantic + # meaning to the body, which it should not have according to the + # HTTP 1.1 spec. + elif http_method.upper() in ('GET', 'HEAD') and has_params: + raise ValueError('GET/HEAD requests should not include body.') + + # generate the basic OAuth parameters + request.oauth_params = self.get_oauth_params(request) + + # generate the signature + request.oauth_params.append( + ('oauth_signature', self.get_oauth_signature(request))) + + # render the signed request and return it + uri, headers, body = self._render(request, formencode=True, + realm=(realm or self.realm)) + + if self.decoding: + log.debug('Encoding URI, headers and body to %s.', self.decoding) + uri = uri.encode(self.decoding) + body = body.encode(self.decoding) if body else body + new_headers = {} + for k, v in headers.items(): + new_headers[k.encode(self.decoding)] = v.encode(self.decoding) + headers = new_headers + return uri, headers, body diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/__init__.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/__init__.py new file mode 100644 index 0000000000..9f30389f23 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/__init__.py @@ -0,0 +1,8 @@ +from .access_token import AccessTokenEndpoint +from .authorization import AuthorizationEndpoint +from .base import BaseEndpoint +from .request_token import RequestTokenEndpoint +from .resource import ResourceEndpoint +from .signature_only import SignatureOnlyEndpoint + +from .pre_configured import WebApplicationServer # isort:skip diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/access_token.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/access_token.py new file mode 100644 index 0000000000..13665db08f --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/access_token.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.access_token +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the access token provider logic of +OAuth 1.0 RFC 5849. It validates the correctness of access token requests, +creates and persists tokens as well as create the proper response to be +returned to the client. +""" +import logging + +from oauthlib.common import urlencode + +from .. import errors +from .base import BaseEndpoint + +log = logging.getLogger(__name__) + + +class AccessTokenEndpoint(BaseEndpoint): + + """An endpoint responsible for providing OAuth 1 access tokens. + + Typical use is to instantiate with a request validator and invoke the + ``create_access_token_response`` from a view function. The tuple returned + has all information necessary (body, status, headers) to quickly form + and return a proper response. See :doc:`/oauth1/validator` for details on which + validator methods to implement for this endpoint. + """ + + def create_access_token(self, request, credentials): + """Create and save a new access token. + + Similar to OAuth 2, indication of granted scopes will be included as a + space separated list in ``oauth_authorized_realms``. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The token as an urlencoded string. + """ + request.realms = self.request_validator.get_realms( + request.resource_owner_key, request) + token = { + 'oauth_token': self.token_generator(), + 'oauth_token_secret': self.token_generator(), + # Backport the authorized scopes indication used in OAuth2 + 'oauth_authorized_realms': ' '.join(request.realms) + } + token.update(credentials) + self.request_validator.save_access_token(token, request) + return urlencode(token.items()) + + def create_access_token_response(self, uri, http_method='GET', body=None, + headers=None, credentials=None): + """Create an access token response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param credentials: A list of extra credentials to include in the token. + :returns: A tuple of 3 elements. + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + + An example of a valid request:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import AccessTokenEndpoint + >>> endpoint = AccessTokenEndpoint(your_validator) + >>> h, b, s = endpoint.create_access_token_response( + ... 'https://your.provider/access_token?foo=bar', + ... headers={ + ... 'Authorization': 'OAuth oauth_token=234lsdkf....' + ... }, + ... credentials={ + ... 'my_specific': 'argument', + ... }) + >>> h + {'Content-Type': 'application/x-www-form-urlencoded'} + >>> b + 'oauth_token=lsdkfol23w54jlksdef&oauth_token_secret=qwe089234lkjsdf&oauth_authorized_realms=movies+pics&my_specific=argument' + >>> s + 200 + + An response to invalid request would have a different body and status:: + + >>> b + 'error=invalid_request&description=missing+resource+owner+key' + >>> s + 400 + + The same goes for an an unauthorized request: + + >>> b + '' + >>> s + 401 + """ + resp_headers = {'Content-Type': 'application/x-www-form-urlencoded'} + try: + request = self._create_request(uri, http_method, body, headers) + valid, processed_request = self.validate_access_token_request( + request) + if valid: + token = self.create_access_token(request, credentials or {}) + self.request_validator.invalidate_request_token( + request.client_key, + request.resource_owner_key, + request) + return resp_headers, token, 200 + else: + return {}, None, 401 + except errors.OAuth1Error as e: + return resp_headers, e.urlencoded, e.status_code + + def validate_access_token_request(self, request): + """Validate an access token request. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :raises: OAuth1Error if the request is invalid. + :returns: A tuple of 2 elements. + 1. The validation result (True or False). + 2. The request object. + """ + self._check_transport_security(request) + self._check_mandatory_parameters(request) + + if not request.resource_owner_key: + raise errors.InvalidRequestError( + description='Missing resource owner.') + + if not self.request_validator.check_request_token( + request.resource_owner_key): + raise errors.InvalidRequestError( + description='Invalid resource owner key format.') + + if not request.verifier: + raise errors.InvalidRequestError( + description='Missing verifier.') + + if not self.request_validator.check_verifier(request.verifier): + raise errors.InvalidRequestError( + description='Invalid verifier format.') + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request, + request_token=request.resource_owner_key): + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid or expired token. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy token is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable resource owner enumeration + valid_resource_owner = self.request_validator.validate_request_token( + request.client_key, request.resource_owner_key, request) + if not valid_resource_owner: + request.resource_owner_key = self.request_validator.dummy_request_token + + # The server MUST verify (Section 3.2) the validity of the request, + # ensure that the resource owner has authorized the provisioning of + # token credentials to the client, and ensure that the temporary + # credentials have not expired or been used before. The server MUST + # also verify the verification code received from the client. + # .. _`Section 3.2`: https://tools.ietf.org/html/rfc5849#section-3.2 + # + # Note that early exit would enable resource owner authorization + # verifier enumertion. + valid_verifier = self.request_validator.validate_verifier( + request.client_key, + request.resource_owner_key, + request.verifier, + request) + + valid_signature = self._check_signature(request, is_token_request=True) + + # log the results to the validator_log + # this lets us handle internal reporting and analysis + request.validator_log['client'] = valid_client + request.validator_log['resource_owner'] = valid_resource_owner + request.validator_log['verifier'] = valid_verifier + request.validator_log['signature'] = valid_signature + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_resource_owner, valid_verifier, + valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client:, %s", valid_client) + log.info("Valid token:, %s", valid_resource_owner) + log.info("Valid verifier:, %s", valid_verifier) + log.info("Valid signature:, %s", valid_signature) + return v, request diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/authorization.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/authorization.py new file mode 100644 index 0000000000..00d9576b01 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/authorization.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.authorization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" +from urllib.parse import urlencode + +from oauthlib.common import add_params_to_uri + +from .. import errors +from .base import BaseEndpoint + + +class AuthorizationEndpoint(BaseEndpoint): + + """An endpoint responsible for letting authenticated users authorize access + to their protected resources to a client. + + Typical use would be to have two views, one for displaying the authorization + form and one to process said form on submission. + + The first view will want to utilize ``get_realms_and_credentials`` to fetch + requested realms and useful client credentials, such as name and + description, to be used when creating the authorization form. + + During form processing you can use ``create_authorization_response`` to + validate the request, create a verifier as well as prepare the final + redirection URI used to send the user back to the client. + + See :doc:`/oauth1/validator` for details on which validator methods to implement + for this endpoint. + """ + + def create_verifier(self, request, credentials): + """Create and save a new request token. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param credentials: A dict of extra token credentials. + :returns: The verifier as a dict. + """ + verifier = { + 'oauth_token': request.resource_owner_key, + 'oauth_verifier': self.token_generator(), + } + verifier.update(credentials) + self.request_validator.save_verifier( + request.resource_owner_key, verifier, request) + return verifier + + def create_authorization_response(self, uri, http_method='GET', body=None, + headers=None, realms=None, credentials=None): + """Create an authorization response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param credentials: A list of credentials to include in the verifier. + :returns: A tuple of 3 elements. + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + + If the callback URI tied to the current token is "oob", a response with + a 200 status code will be returned. In this case, it may be desirable to + modify the response to better display the verifier to the client. + + An example of an authorization request:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import AuthorizationEndpoint + >>> endpoint = AuthorizationEndpoint(your_validator) + >>> h, b, s = endpoint.create_authorization_response( + ... 'https://your.provider/authorize?oauth_token=...', + ... credentials={ + ... 'extra': 'argument', + ... }) + >>> h + {'Location': 'https://the.client/callback?oauth_verifier=...&extra=argument'} + >>> b + None + >>> s + 302 + + An example of a request with an "oob" callback:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import AuthorizationEndpoint + >>> endpoint = AuthorizationEndpoint(your_validator) + >>> h, b, s = endpoint.create_authorization_response( + ... 'https://your.provider/authorize?foo=bar', + ... credentials={ + ... 'extra': 'argument', + ... }) + >>> h + {'Content-Type': 'application/x-www-form-urlencoded'} + >>> b + 'oauth_verifier=...&extra=argument' + >>> s + 200 + """ + request = self._create_request(uri, http_method=http_method, body=body, + headers=headers) + + if not request.resource_owner_key: + raise errors.InvalidRequestError( + 'Missing mandatory parameter oauth_token.') + if not self.request_validator.verify_request_token( + request.resource_owner_key, request): + raise errors.InvalidClientError() + + request.realms = realms + if (request.realms and not self.request_validator.verify_realms( + request.resource_owner_key, request.realms, request)): + raise errors.InvalidRequestError( + description=('User granted access to realms outside of ' + 'what the client may request.')) + + verifier = self.create_verifier(request, credentials or {}) + redirect_uri = self.request_validator.get_redirect_uri( + request.resource_owner_key, request) + if redirect_uri == 'oob': + response_headers = { + 'Content-Type': 'application/x-www-form-urlencoded'} + response_body = urlencode(verifier) + return response_headers, response_body, 200 + else: + populated_redirect = add_params_to_uri( + redirect_uri, verifier.items()) + return {'Location': populated_redirect}, None, 302 + + def get_realms_and_credentials(self, uri, http_method='GET', body=None, + headers=None): + """Fetch realms and credentials for the presented request token. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :returns: A tuple of 2 elements. + 1. A list of request realms. + 2. A dict of credentials which may be useful in creating the + authorization form. + """ + request = self._create_request(uri, http_method=http_method, body=body, + headers=headers) + + if not self.request_validator.verify_request_token( + request.resource_owner_key, request): + raise errors.InvalidClientError() + + realms = self.request_validator.get_realms( + request.resource_owner_key, request) + return realms, {'resource_owner_key': request.resource_owner_key} diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/base.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/base.py new file mode 100644 index 0000000000..7831be7c5e --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/base.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.base +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" +import time + +from oauthlib.common import CaseInsensitiveDict, Request, generate_token + +from .. import ( + CONTENT_TYPE_FORM_URLENCODED, SIGNATURE_HMAC_SHA1, SIGNATURE_HMAC_SHA256, + SIGNATURE_HMAC_SHA512, SIGNATURE_PLAINTEXT, SIGNATURE_RSA_SHA1, + SIGNATURE_RSA_SHA256, SIGNATURE_RSA_SHA512, SIGNATURE_TYPE_AUTH_HEADER, + SIGNATURE_TYPE_BODY, SIGNATURE_TYPE_QUERY, errors, signature, utils, +) + + +class BaseEndpoint: + + def __init__(self, request_validator, token_generator=None): + self.request_validator = request_validator + self.token_generator = token_generator or generate_token + + def _get_signature_type_and_params(self, request): + """Extracts parameters from query, headers and body. Signature type + is set to the source in which parameters were found. + """ + # Per RFC5849, only the Authorization header may contain the 'realm' + # optional parameter. + header_params = signature.collect_parameters(headers=request.headers, + exclude_oauth_signature=False, with_realm=True) + body_params = signature.collect_parameters(body=request.body, + exclude_oauth_signature=False) + query_params = signature.collect_parameters(uri_query=request.uri_query, + exclude_oauth_signature=False) + + params = [] + params.extend(header_params) + params.extend(body_params) + params.extend(query_params) + signature_types_with_oauth_params = list(filter(lambda s: s[2], ( + (SIGNATURE_TYPE_AUTH_HEADER, params, + utils.filter_oauth_params(header_params)), + (SIGNATURE_TYPE_BODY, params, + utils.filter_oauth_params(body_params)), + (SIGNATURE_TYPE_QUERY, params, + utils.filter_oauth_params(query_params)) + ))) + + if len(signature_types_with_oauth_params) > 1: + found_types = [s[0] for s in signature_types_with_oauth_params] + raise errors.InvalidRequestError( + description=('oauth_ params must come from only 1 signature' + 'type but were found in %s', + ', '.join(found_types))) + + try: + signature_type, params, oauth_params = signature_types_with_oauth_params[ + 0] + except IndexError: + raise errors.InvalidRequestError( + description='Missing mandatory OAuth parameters.') + + return signature_type, params, oauth_params + + def _create_request(self, uri, http_method, body, headers): + # Only include body data from x-www-form-urlencoded requests + headers = CaseInsensitiveDict(headers or {}) + if ("Content-Type" in headers and + CONTENT_TYPE_FORM_URLENCODED in headers["Content-Type"]): + request = Request(uri, http_method, body, headers) + else: + request = Request(uri, http_method, '', headers) + + signature_type, params, oauth_params = ( + self._get_signature_type_and_params(request)) + + # The server SHOULD return a 400 (Bad Request) status code when + # receiving a request with duplicated protocol parameters. + if len(dict(oauth_params)) != len(oauth_params): + raise errors.InvalidRequestError( + description='Duplicate OAuth1 entries.') + + oauth_params = dict(oauth_params) + request.signature = oauth_params.get('oauth_signature') + request.client_key = oauth_params.get('oauth_consumer_key') + request.resource_owner_key = oauth_params.get('oauth_token') + request.nonce = oauth_params.get('oauth_nonce') + request.timestamp = oauth_params.get('oauth_timestamp') + request.redirect_uri = oauth_params.get('oauth_callback') + request.verifier = oauth_params.get('oauth_verifier') + request.signature_method = oauth_params.get('oauth_signature_method') + request.realm = dict(params).get('realm') + request.oauth_params = oauth_params + + # Parameters to Client depend on signature method which may vary + # for each request. Note that HMAC-SHA1 and PLAINTEXT share parameters + request.params = [(k, v) for k, v in params if k != "oauth_signature"] + + if 'realm' in request.headers.get('Authorization', ''): + request.params = [(k, v) + for k, v in request.params if k != "realm"] + + return request + + def _check_transport_security(self, request): + # TODO: move into oauthlib.common from oauth2.utils + if (self.request_validator.enforce_ssl and + not request.uri.lower().startswith("https://")): + raise errors.InsecureTransportError() + + def _check_mandatory_parameters(self, request): + # The server SHOULD return a 400 (Bad Request) status code when + # receiving a request with missing parameters. + if not all((request.signature, request.client_key, + request.nonce, request.timestamp, + request.signature_method)): + raise errors.InvalidRequestError( + description='Missing mandatory OAuth parameters.') + + # OAuth does not mandate a particular signature method, as each + # implementation can have its own unique requirements. Servers are + # free to implement and document their own custom methods. + # Recommending any particular method is beyond the scope of this + # specification. Implementers should review the Security + # Considerations section (`Section 4`_) before deciding on which + # method to support. + # .. _`Section 4`: https://tools.ietf.org/html/rfc5849#section-4 + if (not request.signature_method in + self.request_validator.allowed_signature_methods): + raise errors.InvalidSignatureMethodError( + description="Invalid signature, {} not in {!r}.".format( + request.signature_method, + self.request_validator.allowed_signature_methods)) + + # Servers receiving an authenticated request MUST validate it by: + # If the "oauth_version" parameter is present, ensuring its value is + # "1.0". + if ('oauth_version' in request.oauth_params and + request.oauth_params['oauth_version'] != '1.0'): + raise errors.InvalidRequestError( + description='Invalid OAuth version.') + + # The timestamp value MUST be a positive integer. Unless otherwise + # specified by the server's documentation, the timestamp is expressed + # in the number of seconds since January 1, 1970 00:00:00 GMT. + if len(request.timestamp) != 10: + raise errors.InvalidRequestError( + description='Invalid timestamp size') + + try: + ts = int(request.timestamp) + + except ValueError: + raise errors.InvalidRequestError( + description='Timestamp must be an integer.') + + else: + # To avoid the need to retain an infinite number of nonce values for + # future checks, servers MAY choose to restrict the time period after + # which a request with an old timestamp is rejected. + if abs(time.time() - ts) > self.request_validator.timestamp_lifetime: + raise errors.InvalidRequestError( + description=('Timestamp given is invalid, differ from ' + 'allowed by over %s seconds.' % ( + self.request_validator.timestamp_lifetime))) + + # Provider specific validation of parameters, used to enforce + # restrictions such as character set and length. + if not self.request_validator.check_client_key(request.client_key): + raise errors.InvalidRequestError( + description='Invalid client key format.') + + if not self.request_validator.check_nonce(request.nonce): + raise errors.InvalidRequestError( + description='Invalid nonce format.') + + def _check_signature(self, request, is_token_request=False): + # ---- RSA Signature verification ---- + if request.signature_method == SIGNATURE_RSA_SHA1 or \ + request.signature_method == SIGNATURE_RSA_SHA256 or \ + request.signature_method == SIGNATURE_RSA_SHA512: + # RSA-based signature method + + # The server verifies the signature per `[RFC3447] section 8.2.2`_ + # .. _`[RFC3447] section 8.2.2`: https://tools.ietf.org/html/rfc3447#section-8.2.1 + + rsa_key = self.request_validator.get_rsa_key( + request.client_key, request) + + if request.signature_method == SIGNATURE_RSA_SHA1: + valid_signature = signature.verify_rsa_sha1(request, rsa_key) + elif request.signature_method == SIGNATURE_RSA_SHA256: + valid_signature = signature.verify_rsa_sha256(request, rsa_key) + elif request.signature_method == SIGNATURE_RSA_SHA512: + valid_signature = signature.verify_rsa_sha512(request, rsa_key) + else: + valid_signature = False + + # ---- HMAC or Plaintext Signature verification ---- + else: + # Non-RSA based signature method + + # Servers receiving an authenticated request MUST validate it by: + # Recalculating the request signature independently as described in + # `Section 3.4`_ and comparing it to the value received from the + # client via the "oauth_signature" parameter. + # .. _`Section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 + + client_secret = self.request_validator.get_client_secret( + request.client_key, request) + + resource_owner_secret = None + if request.resource_owner_key: + if is_token_request: + resource_owner_secret = \ + self.request_validator.get_request_token_secret( + request.client_key, request.resource_owner_key, + request) + else: + resource_owner_secret = \ + self.request_validator.get_access_token_secret( + request.client_key, request.resource_owner_key, + request) + + if request.signature_method == SIGNATURE_HMAC_SHA1: + valid_signature = signature.verify_hmac_sha1( + request, client_secret, resource_owner_secret) + elif request.signature_method == SIGNATURE_HMAC_SHA256: + valid_signature = signature.verify_hmac_sha256( + request, client_secret, resource_owner_secret) + elif request.signature_method == SIGNATURE_HMAC_SHA512: + valid_signature = signature.verify_hmac_sha512( + request, client_secret, resource_owner_secret) + elif request.signature_method == SIGNATURE_PLAINTEXT: + valid_signature = signature.verify_plaintext( + request, client_secret, resource_owner_secret) + else: + valid_signature = False + + return valid_signature diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/pre_configured.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/pre_configured.py new file mode 100644 index 0000000000..23e3cfc84e --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/pre_configured.py @@ -0,0 +1,14 @@ +from . import ( + AccessTokenEndpoint, AuthorizationEndpoint, RequestTokenEndpoint, + ResourceEndpoint, +) + + +class WebApplicationServer(RequestTokenEndpoint, AuthorizationEndpoint, + AccessTokenEndpoint, ResourceEndpoint): + + def __init__(self, request_validator): + RequestTokenEndpoint.__init__(self, request_validator) + AuthorizationEndpoint.__init__(self, request_validator) + AccessTokenEndpoint.__init__(self, request_validator) + ResourceEndpoint.__init__(self, request_validator) diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/request_token.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/request_token.py new file mode 100644 index 0000000000..0323cfb845 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/request_token.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.request_token +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the request token provider logic of +OAuth 1.0 RFC 5849. It validates the correctness of request token requests, +creates and persists tokens as well as create the proper response to be +returned to the client. +""" +import logging + +from oauthlib.common import urlencode + +from .. import errors +from .base import BaseEndpoint + +log = logging.getLogger(__name__) + + +class RequestTokenEndpoint(BaseEndpoint): + + """An endpoint responsible for providing OAuth 1 request tokens. + + Typical use is to instantiate with a request validator and invoke the + ``create_request_token_response`` from a view function. The tuple returned + has all information necessary (body, status, headers) to quickly form + and return a proper response. See :doc:`/oauth1/validator` for details on which + validator methods to implement for this endpoint. + """ + + def create_request_token(self, request, credentials): + """Create and save a new request token. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param credentials: A dict of extra token credentials. + :returns: The token as an urlencoded string. + """ + token = { + 'oauth_token': self.token_generator(), + 'oauth_token_secret': self.token_generator(), + 'oauth_callback_confirmed': 'true' + } + token.update(credentials) + self.request_validator.save_request_token(token, request) + return urlencode(token.items()) + + def create_request_token_response(self, uri, http_method='GET', body=None, + headers=None, credentials=None): + """Create a request token response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param credentials: A list of extra credentials to include in the token. + :returns: A tuple of 3 elements. + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + + An example of a valid request:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import RequestTokenEndpoint + >>> endpoint = RequestTokenEndpoint(your_validator) + >>> h, b, s = endpoint.create_request_token_response( + ... 'https://your.provider/request_token?foo=bar', + ... headers={ + ... 'Authorization': 'OAuth realm=movies user, oauth_....' + ... }, + ... credentials={ + ... 'my_specific': 'argument', + ... }) + >>> h + {'Content-Type': 'application/x-www-form-urlencoded'} + >>> b + 'oauth_token=lsdkfol23w54jlksdef&oauth_token_secret=qwe089234lkjsdf&oauth_callback_confirmed=true&my_specific=argument' + >>> s + 200 + + An response to invalid request would have a different body and status:: + + >>> b + 'error=invalid_request&description=missing+callback+uri' + >>> s + 400 + + The same goes for an an unauthorized request: + + >>> b + '' + >>> s + 401 + """ + resp_headers = {'Content-Type': 'application/x-www-form-urlencoded'} + try: + request = self._create_request(uri, http_method, body, headers) + valid, processed_request = self.validate_request_token_request( + request) + if valid: + token = self.create_request_token(request, credentials or {}) + return resp_headers, token, 200 + else: + return {}, None, 401 + except errors.OAuth1Error as e: + return resp_headers, e.urlencoded, e.status_code + + def validate_request_token_request(self, request): + """Validate a request token request. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :raises: OAuth1Error if the request is invalid. + :returns: A tuple of 2 elements. + 1. The validation result (True or False). + 2. The request object. + """ + self._check_transport_security(request) + self._check_mandatory_parameters(request) + + if request.realm: + request.realms = request.realm.split(' ') + else: + request.realms = self.request_validator.get_default_realms( + request.client_key, request) + if not self.request_validator.check_realms(request.realms): + raise errors.InvalidRequestError( + description='Invalid realm {}. Allowed are {!r}.'.format( + request.realms, self.request_validator.realms)) + + if not request.redirect_uri: + raise errors.InvalidRequestError( + description='Missing callback URI.') + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request, + request_token=request.resource_owner_key): + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + # Note that `realm`_ is only used in authorization headers and how + # it should be interpreted is not included in the OAuth spec. + # However they could be seen as a scope or realm to which the + # client has access and as such every client should be checked + # to ensure it is authorized access to that scope or realm. + # .. _`realm`: https://tools.ietf.org/html/rfc2617#section-1.2 + # + # Note that early exit would enable client realm access enumeration. + # + # The require_realm indicates this is the first step in the OAuth + # workflow where a client requests access to a specific realm. + # This first step (obtaining request token) need not require a realm + # and can then be identified by checking the require_resource_owner + # flag and absence of realm. + # + # Clients obtaining an access token will not supply a realm and it will + # not be checked. Instead the previously requested realm should be + # transferred from the request token to the access token. + # + # Access to protected resources will always validate the realm but note + # that the realm is now tied to the access token and not provided by + # the client. + valid_realm = self.request_validator.validate_requested_realms( + request.client_key, request.realms, request) + + # Callback is normally never required, except for requests for + # a Temporary Credential as described in `Section 2.1`_ + # .._`Section 2.1`: https://tools.ietf.org/html/rfc5849#section-2.1 + valid_redirect = self.request_validator.validate_redirect_uri( + request.client_key, request.redirect_uri, request) + if not request.redirect_uri: + raise NotImplementedError('Redirect URI must either be provided ' + 'or set to a default during validation.') + + valid_signature = self._check_signature(request) + + # log the results to the validator_log + # this lets us handle internal reporting and analysis + request.validator_log['client'] = valid_client + request.validator_log['realm'] = valid_realm + request.validator_log['callback'] = valid_redirect + request.validator_log['signature'] = valid_signature + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_realm, valid_redirect, valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client: %s.", valid_client) + log.info("Valid realm: %s.", valid_realm) + log.info("Valid callback: %s.", valid_redirect) + log.info("Valid signature: %s.", valid_signature) + return v, request diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/resource.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/resource.py new file mode 100644 index 0000000000..8641152e4e --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/resource.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.resource +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the resource protection provider logic of +OAuth 1.0 RFC 5849. +""" +import logging + +from .. import errors +from .base import BaseEndpoint + +log = logging.getLogger(__name__) + + +class ResourceEndpoint(BaseEndpoint): + + """An endpoint responsible for protecting resources. + + Typical use is to instantiate with a request validator and invoke the + ``validate_protected_resource_request`` in a decorator around a view + function. If the request is valid, invoke and return the response of the + view. If invalid create and return an error response directly from the + decorator. + + See :doc:`/oauth1/validator` for details on which validator methods to implement + for this endpoint. + + An example decorator:: + + from functools import wraps + from your_validator import your_validator + from oauthlib.oauth1 import ResourceEndpoint + endpoint = ResourceEndpoint(your_validator) + + def require_oauth(realms=None): + def decorator(f): + @wraps(f) + def wrapper(request, *args, **kwargs): + v, r = provider.validate_protected_resource_request( + request.url, + http_method=request.method, + body=request.data, + headers=request.headers, + realms=realms or []) + if v: + return f(*args, **kwargs) + else: + return abort(403) + """ + + def validate_protected_resource_request(self, uri, http_method='GET', + body=None, headers=None, realms=None): + """Create a request token response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param realms: A list of realms the resource is protected under. + This will be supplied to the ``validate_realms`` + method of the request validator. + :returns: A tuple of 2 elements. + 1. True if valid, False otherwise. + 2. An oauthlib.common.Request object. + """ + try: + request = self._create_request(uri, http_method, body, headers) + except errors.OAuth1Error: + return False, None + + try: + self._check_transport_security(request) + self._check_mandatory_parameters(request) + except errors.OAuth1Error: + return False, request + + if not request.resource_owner_key: + return False, request + + if not self.request_validator.check_access_token( + request.resource_owner_key): + return False, request + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request, + access_token=request.resource_owner_key): + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid or expired token. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy token is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable resource owner enumeration + valid_resource_owner = self.request_validator.validate_access_token( + request.client_key, request.resource_owner_key, request) + if not valid_resource_owner: + request.resource_owner_key = self.request_validator.dummy_access_token + + # Note that `realm`_ is only used in authorization headers and how + # it should be interpreted is not included in the OAuth spec. + # However they could be seen as a scope or realm to which the + # client has access and as such every client should be checked + # to ensure it is authorized access to that scope or realm. + # .. _`realm`: https://tools.ietf.org/html/rfc2617#section-1.2 + # + # Note that early exit would enable client realm access enumeration. + # + # The require_realm indicates this is the first step in the OAuth + # workflow where a client requests access to a specific realm. + # This first step (obtaining request token) need not require a realm + # and can then be identified by checking the require_resource_owner + # flag and absence of realm. + # + # Clients obtaining an access token will not supply a realm and it will + # not be checked. Instead the previously requested realm should be + # transferred from the request token to the access token. + # + # Access to protected resources will always validate the realm but note + # that the realm is now tied to the access token and not provided by + # the client. + valid_realm = self.request_validator.validate_realms(request.client_key, + request.resource_owner_key, request, uri=request.uri, + realms=realms) + + valid_signature = self._check_signature(request) + + # log the results to the validator_log + # this lets us handle internal reporting and analysis + request.validator_log['client'] = valid_client + request.validator_log['resource_owner'] = valid_resource_owner + request.validator_log['realm'] = valid_realm + request.validator_log['signature'] = valid_signature + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_resource_owner, valid_realm, + valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client: %s", valid_client) + log.info("Valid token: %s", valid_resource_owner) + log.info("Valid realm: %s", valid_realm) + log.info("Valid signature: %s", valid_signature) + return v, request diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/signature_only.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/signature_only.py new file mode 100644 index 0000000000..d693ccb7f6 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/endpoints/signature_only.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.signature_only +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the signing logic of OAuth 1.0 RFC 5849. +""" + +import logging + +from .. import errors +from .base import BaseEndpoint + +log = logging.getLogger(__name__) + + +class SignatureOnlyEndpoint(BaseEndpoint): + + """An endpoint only responsible for verifying an oauth signature.""" + + def validate_request(self, uri, http_method='GET', + body=None, headers=None): + """Validate a signed OAuth request. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :returns: A tuple of 2 elements. + 1. True if valid, False otherwise. + 2. An oauthlib.common.Request object. + """ + try: + request = self._create_request(uri, http_method, body, headers) + except errors.OAuth1Error as err: + log.info( + 'Exception caught while validating request, %s.' % err) + return False, None + + try: + self._check_transport_security(request) + self._check_mandatory_parameters(request) + except errors.OAuth1Error as err: + log.info( + 'Exception caught while validating request, %s.' % err) + return False, request + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request): + log.debug('[Failure] verification failed: timestamp/nonce') + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + valid_signature = self._check_signature(request) + + # log the results to the validator_log + # this lets us handle internal reporting and analysis + request.validator_log['client'] = valid_client + request.validator_log['signature'] = valid_signature + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client: %s", valid_client) + log.info("Valid signature: %s", valid_signature) + return v, request diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/errors.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/errors.py new file mode 100644 index 0000000000..8774d40741 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/errors.py @@ -0,0 +1,76 @@ +""" +oauthlib.oauth1.rfc5849.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth 1 clients and provicers to represent the spec +defined error responses for all four core grant types. +""" +from oauthlib.common import add_params_to_uri, urlencode + + +class OAuth1Error(Exception): + error = None + description = '' + + def __init__(self, description=None, uri=None, status_code=400, + request=None): + """ + description: A human-readable ASCII [USASCII] text providing + additional information, used to assist the client + developer in understanding the error that occurred. + Values for the "error_description" parameter MUST NOT + include characters outside the set + x20-21 / x23-5B / x5D-7E. + + uri: A URI identifying a human-readable web page with information + about the error, used to provide the client developer with + additional information about the error. Values for the + "error_uri" parameter MUST conform to the URI- Reference + syntax, and thus MUST NOT include characters outside the set + x21 / x23-5B / x5D-7E. + + state: A CSRF protection value received from the client. + + request: Oauthlib Request object + """ + self.description = description or self.description + message = '({}) {}'.format(self.error, self.description) + if request: + message += ' ' + repr(request) + super().__init__(message) + + self.uri = uri + self.status_code = status_code + + def in_uri(self, uri): + return add_params_to_uri(uri, self.twotuples) + + @property + def twotuples(self): + error = [('error', self.error)] + if self.description: + error.append(('error_description', self.description)) + if self.uri: + error.append(('error_uri', self.uri)) + return error + + @property + def urlencoded(self): + return urlencode(self.twotuples) + + +class InsecureTransportError(OAuth1Error): + error = 'insecure_transport_protocol' + description = 'Only HTTPS connections are permitted.' + + +class InvalidSignatureMethodError(OAuth1Error): + error = 'invalid_signature_method' + + +class InvalidRequestError(OAuth1Error): + error = 'invalid_request' + + +class InvalidClientError(OAuth1Error): + error = 'invalid_client' diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/parameters.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/parameters.py new file mode 100644 index 0000000000..2163772df3 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/parameters.py @@ -0,0 +1,133 @@ +""" +oauthlib.parameters +~~~~~~~~~~~~~~~~~~~ + +This module contains methods related to `section 3.5`_ of the OAuth 1.0a spec. + +.. _`section 3.5`: https://tools.ietf.org/html/rfc5849#section-3.5 +""" +from urllib.parse import urlparse, urlunparse + +from oauthlib.common import extract_params, urlencode + +from . import utils + + +# TODO: do we need filter_params now that oauth_params are handled by Request? +# We can easily pass in just oauth protocol params. +@utils.filter_params +def prepare_headers(oauth_params, headers=None, realm=None): + """**Prepare the Authorization header.** + Per `section 3.5.1`_ of the spec. + + Protocol parameters can be transmitted using the HTTP "Authorization" + header field as defined by `RFC2617`_ with the auth-scheme name set to + "OAuth" (case insensitive). + + For example:: + + Authorization: OAuth realm="Example", + oauth_consumer_key="0685bd9184jfhq22", + oauth_token="ad180jjd733klru7", + oauth_signature_method="HMAC-SHA1", + oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D", + oauth_timestamp="137131200", + oauth_nonce="4572616e48616d6d65724c61686176", + oauth_version="1.0" + + + .. _`section 3.5.1`: https://tools.ietf.org/html/rfc5849#section-3.5.1 + .. _`RFC2617`: https://tools.ietf.org/html/rfc2617 + """ + headers = headers or {} + + # Protocol parameters SHALL be included in the "Authorization" header + # field as follows: + authorization_header_parameters_parts = [] + for oauth_parameter_name, value in oauth_params: + # 1. Parameter names and values are encoded per Parameter Encoding + # (`Section 3.6`_) + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + escaped_name = utils.escape(oauth_parameter_name) + escaped_value = utils.escape(value) + + # 2. Each parameter's name is immediately followed by an "=" character + # (ASCII code 61), a """ character (ASCII code 34), the parameter + # value (MAY be empty), and another """ character (ASCII code 34). + part = '{}="{}"'.format(escaped_name, escaped_value) + + authorization_header_parameters_parts.append(part) + + # 3. Parameters are separated by a "," character (ASCII code 44) and + # OPTIONAL linear whitespace per `RFC2617`_. + # + # .. _`RFC2617`: https://tools.ietf.org/html/rfc2617 + authorization_header_parameters = ', '.join( + authorization_header_parameters_parts) + + # 4. The OPTIONAL "realm" parameter MAY be added and interpreted per + # `RFC2617 section 1.2`_. + # + # .. _`RFC2617 section 1.2`: https://tools.ietf.org/html/rfc2617#section-1.2 + if realm: + # NOTE: realm should *not* be escaped + authorization_header_parameters = ('realm="%s", ' % realm + + authorization_header_parameters) + + # the auth-scheme name set to "OAuth" (case insensitive). + authorization_header = 'OAuth %s' % authorization_header_parameters + + # contribute the Authorization header to the given headers + full_headers = {} + full_headers.update(headers) + full_headers['Authorization'] = authorization_header + return full_headers + + +def _append_params(oauth_params, params): + """Append OAuth params to an existing set of parameters. + + Both params and oauth_params is must be lists of 2-tuples. + + Per `section 3.5.2`_ and `3.5.3`_ of the spec. + + .. _`section 3.5.2`: https://tools.ietf.org/html/rfc5849#section-3.5.2 + .. _`3.5.3`: https://tools.ietf.org/html/rfc5849#section-3.5.3 + + """ + merged = list(params) + merged.extend(oauth_params) + # The request URI / entity-body MAY include other request-specific + # parameters, in which case, the protocol parameters SHOULD be appended + # following the request-specific parameters, properly separated by an "&" + # character (ASCII code 38) + merged.sort(key=lambda i: i[0].startswith('oauth_')) + return merged + + +def prepare_form_encoded_body(oauth_params, body): + """Prepare the Form-Encoded Body. + + Per `section 3.5.2`_ of the spec. + + .. _`section 3.5.2`: https://tools.ietf.org/html/rfc5849#section-3.5.2 + + """ + # append OAuth params to the existing body + return _append_params(oauth_params, body) + + +def prepare_request_uri_query(oauth_params, uri): + """Prepare the Request URI Query. + + Per `section 3.5.3`_ of the spec. + + .. _`section 3.5.3`: https://tools.ietf.org/html/rfc5849#section-3.5.3 + + """ + # append OAuth params to the existing set of query components + sch, net, path, par, query, fra = urlparse(uri) + query = urlencode( + _append_params(oauth_params, extract_params(query) or [])) + return urlunparse((sch, net, path, par, query, fra)) diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/request_validator.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/request_validator.py new file mode 100644 index 0000000000..e937aabf40 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/request_validator.py @@ -0,0 +1,849 @@ +""" +oauthlib.oauth1.rfc5849 +~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" +from . import SIGNATURE_METHODS, utils + + +class RequestValidator: + + """A validator/datastore interaction base class for OAuth 1 providers. + + OAuth providers should inherit from RequestValidator and implement the + methods and properties outlined below. Further details are provided in the + documentation for each method and property. + + Methods used to check the format of input parameters. Common tests include + length, character set, membership, range or pattern. These tests are + referred to as `whitelisting or blacklisting`_. Whitelisting is better + but blacklisting can be useful to spot malicious activity. + The following have methods a default implementation: + + - check_client_key + - check_request_token + - check_access_token + - check_nonce + - check_verifier + - check_realms + + The methods above default to whitelist input parameters, checking that they + are alphanumerical and between a minimum and maximum length. Rather than + overloading the methods a few properties can be used to configure these + methods. + + * @safe_characters -> (character set) + * @client_key_length -> (min, max) + * @request_token_length -> (min, max) + * @access_token_length -> (min, max) + * @nonce_length -> (min, max) + * @verifier_length -> (min, max) + * @realms -> [list, of, realms] + + Methods used to validate/invalidate input parameters. These checks usually + hit either persistent or temporary storage such as databases or the + filesystem. See each methods documentation for detailed usage. + The following methods must be implemented: + + - validate_client_key + - validate_request_token + - validate_access_token + - validate_timestamp_and_nonce + - validate_redirect_uri + - validate_requested_realms + - validate_realms + - validate_verifier + - invalidate_request_token + + Methods used to retrieve sensitive information from storage. + The following methods must be implemented: + + - get_client_secret + - get_request_token_secret + - get_access_token_secret + - get_rsa_key + - get_realms + - get_default_realms + - get_redirect_uri + + Methods used to save credentials. + The following methods must be implemented: + + - save_request_token + - save_verifier + - save_access_token + + Methods used to verify input parameters. This methods are used during + authorizing request token by user (AuthorizationEndpoint), to check if + parameters are valid. During token authorization request is not signed, + thus 'validation' methods can not be used. The following methods must be + implemented: + + - verify_realms + - verify_request_token + + To prevent timing attacks it is necessary to not exit early even if the + client key or resource owner key is invalid. Instead dummy values should + be used during the remaining verification process. It is very important + that the dummy client and token are valid input parameters to the methods + get_client_secret, get_rsa_key and get_(access/request)_token_secret and + that the running time of those methods when given a dummy value remain + equivalent to the running time when given a valid client/resource owner. + The following properties must be implemented: + + * @dummy_client + * @dummy_request_token + * @dummy_access_token + + Example implementations have been provided, note that the database used is + a simple dictionary and serves only an illustrative purpose. Use whichever + database suits your project and how to access it is entirely up to you. + The methods are introduced in an order which should make understanding + their use more straightforward and as such it could be worth reading what + follows in chronological order. + + .. _`whitelisting or blacklisting`: https://www.schneier.com/blog/archives/2011/01/whitelisting_vs.html + """ + + def __init__(self): + pass + + @property + def allowed_signature_methods(self): + return SIGNATURE_METHODS + + @property + def safe_characters(self): + return set(utils.UNICODE_ASCII_CHARACTER_SET) + + @property + def client_key_length(self): + return 20, 30 + + @property + def request_token_length(self): + return 20, 30 + + @property + def access_token_length(self): + return 20, 30 + + @property + def timestamp_lifetime(self): + return 600 + + @property + def nonce_length(self): + return 20, 30 + + @property + def verifier_length(self): + return 20, 30 + + @property + def realms(self): + return [] + + @property + def enforce_ssl(self): + return True + + def check_client_key(self, client_key): + """Check that the client key only contains safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.client_key_length + return (set(client_key) <= self.safe_characters and + lower <= len(client_key) <= upper) + + def check_request_token(self, request_token): + """Checks that the request token contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.request_token_length + return (set(request_token) <= self.safe_characters and + lower <= len(request_token) <= upper) + + def check_access_token(self, request_token): + """Checks that the token contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.access_token_length + return (set(request_token) <= self.safe_characters and + lower <= len(request_token) <= upper) + + def check_nonce(self, nonce): + """Checks that the nonce only contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.nonce_length + return (set(nonce) <= self.safe_characters and + lower <= len(nonce) <= upper) + + def check_verifier(self, verifier): + """Checks that the verifier contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.verifier_length + return (set(verifier) <= self.safe_characters and + lower <= len(verifier) <= upper) + + def check_realms(self, realms): + """Check that the realm is one of a set allowed realms.""" + return all(r in self.realms for r in realms) + + def _subclass_must_implement(self, fn): + """ + Returns a NotImplementedError for a function that should be implemented. + :param fn: name of the function + """ + m = "Missing function implementation in {}: {}".format(type(self), fn) + return NotImplementedError(m) + + @property + def dummy_client(self): + """Dummy client used when an invalid client key is supplied. + + :returns: The dummy client key string. + + The dummy client should be associated with either a client secret, + a rsa key or both depending on which signature methods are supported. + Providers should make sure that + + get_client_secret(dummy_client) + get_rsa_key(dummy_client) + + return a valid secret or key for the dummy client. + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise self._subclass_must_implement("dummy_client") + + @property + def dummy_request_token(self): + """Dummy request token used when an invalid token was supplied. + + :returns: The dummy request token string. + + The dummy request token should be associated with a request token + secret such that get_request_token_secret(.., dummy_request_token) + returns a valid secret. + + This method is used by + + * AccessTokenEndpoint + """ + raise self._subclass_must_implement("dummy_request_token") + + @property + def dummy_access_token(self): + """Dummy access token used when an invalid token was supplied. + + :returns: The dummy access token string. + + The dummy access token should be associated with an access token + secret such that get_access_token_secret(.., dummy_access_token) + returns a valid secret. + + This method is used by + + * ResourceEndpoint + """ + raise self._subclass_must_implement("dummy_access_token") + + def get_client_secret(self, client_key, request): + """Retrieves the client secret associated with the client key. + + :param client_key: The client/consumer key. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The client secret as a string. + + This method must allow the use of a dummy client_key value. + Fetching the secret using the dummy key must take the same amount of + time as fetching a secret for a valid client:: + + # Unlikely to be near constant time as it uses two database + # lookups for a valid client, and only one for an invalid. + from your_datastore import ClientSecret + if ClientSecret.has(client_key): + return ClientSecret.get(client_key) + else: + return 'dummy' + + # Aim to mimic number of latency inducing operations no matter + # whether the client is valid or not. + from your_datastore import ClientSecret + return ClientSecret.get(client_key, 'dummy') + + Note that the returned key must be in plaintext. + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise self._subclass_must_implement('get_client_secret') + + def get_request_token_secret(self, client_key, token, request): + """Retrieves the shared secret associated with the request token. + + :param client_key: The client/consumer key. + :param token: The request token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The token secret as a string. + + This method must allow the use of a dummy values and the running time + must be roughly equivalent to that of the running time of valid values:: + + # Unlikely to be near constant time as it uses two database + # lookups for a valid client, and only one for an invalid. + from your_datastore import RequestTokenSecret + if RequestTokenSecret.has(client_key): + return RequestTokenSecret.get((client_key, request_token)) + else: + return 'dummy' + + # Aim to mimic number of latency inducing operations no matter + # whether the client is valid or not. + from your_datastore import RequestTokenSecret + return ClientSecret.get((client_key, request_token), 'dummy') + + Note that the returned key must be in plaintext. + + This method is used by + + * AccessTokenEndpoint + """ + raise self._subclass_must_implement('get_request_token_secret') + + def get_access_token_secret(self, client_key, token, request): + """Retrieves the shared secret associated with the access token. + + :param client_key: The client/consumer key. + :param token: The access token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The token secret as a string. + + This method must allow the use of a dummy values and the running time + must be roughly equivalent to that of the running time of valid values:: + + # Unlikely to be near constant time as it uses two database + # lookups for a valid client, and only one for an invalid. + from your_datastore import AccessTokenSecret + if AccessTokenSecret.has(client_key): + return AccessTokenSecret.get((client_key, request_token)) + else: + return 'dummy' + + # Aim to mimic number of latency inducing operations no matter + # whether the client is valid or not. + from your_datastore import AccessTokenSecret + return ClientSecret.get((client_key, request_token), 'dummy') + + Note that the returned key must be in plaintext. + + This method is used by + + * ResourceEndpoint + """ + raise self._subclass_must_implement("get_access_token_secret") + + def get_default_realms(self, client_key, request): + """Get the default realms for a client. + + :param client_key: The client/consumer key. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The list of default realms associated with the client. + + The list of default realms will be set during client registration and + is outside the scope of OAuthLib. + + This method is used by + + * RequestTokenEndpoint + """ + raise self._subclass_must_implement("get_default_realms") + + def get_realms(self, token, request): + """Get realms associated with a request token. + + :param token: The request token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The list of realms associated with the request token. + + This method is used by + + * AuthorizationEndpoint + * AccessTokenEndpoint + """ + raise self._subclass_must_implement("get_realms") + + def get_redirect_uri(self, token, request): + """Get the redirect URI associated with a request token. + + :param token: The request token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The redirect URI associated with the request token. + + It may be desirable to return a custom URI if the redirect is set to "oob". + In this case, the user will be redirected to the returned URI and at that + endpoint the verifier can be displayed. + + This method is used by + + * AuthorizationEndpoint + """ + raise self._subclass_must_implement("get_redirect_uri") + + def get_rsa_key(self, client_key, request): + """Retrieves a previously stored client provided RSA key. + + :param client_key: The client/consumer key. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: The rsa public key as a string. + + This method must allow the use of a dummy client_key value. Fetching + the rsa key using the dummy key must take the same amount of time + as fetching a key for a valid client. The dummy key must also be of + the same bit length as client keys. + + Note that the key must be returned in plaintext. + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise self._subclass_must_implement("get_rsa_key") + + def invalidate_request_token(self, client_key, request_token, request): + """Invalidates a used request token. + + :param client_key: The client/consumer key. + :param request_token: The request token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: None + + Per `Section 2.3`_ of the spec: + + "The server MUST (...) ensure that the temporary + credentials have not expired or been used before." + + .. _`Section 2.3`: https://tools.ietf.org/html/rfc5849#section-2.3 + + This method should ensure that provided token won't validate anymore. + It can be simply removing RequestToken from storage or setting + specific flag that makes it invalid (note that such flag should be + also validated during request token validation). + + This method is used by + + * AccessTokenEndpoint + """ + raise self._subclass_must_implement("invalidate_request_token") + + def validate_client_key(self, client_key, request): + """Validates that supplied client key is a registered and valid client. + + :param client_key: The client/consumer key. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + Note that if the dummy client is supplied it should validate in same + or nearly the same amount of time as a valid one. + + Ensure latency inducing tasks are mimiced even for dummy clients. + For example, use:: + + from your_datastore import Client + try: + return Client.exists(client_key, access_token) + except DoesNotExist: + return False + + Rather than:: + + from your_datastore import Client + if access_token == self.dummy_access_token: + return False + else: + return Client.exists(client_key, access_token) + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise self._subclass_must_implement("validate_client_key") + + def validate_request_token(self, client_key, token, request): + """Validates that supplied request token is registered and valid. + + :param client_key: The client/consumer key. + :param token: The request token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + Note that if the dummy request_token is supplied it should validate in + the same nearly the same amount of time as a valid one. + + Ensure latency inducing tasks are mimiced even for dummy clients. + For example, use:: + + from your_datastore import RequestToken + try: + return RequestToken.exists(client_key, access_token) + except DoesNotExist: + return False + + Rather than:: + + from your_datastore import RequestToken + if access_token == self.dummy_access_token: + return False + else: + return RequestToken.exists(client_key, access_token) + + This method is used by + + * AccessTokenEndpoint + """ + raise self._subclass_must_implement("validate_request_token") + + def validate_access_token(self, client_key, token, request): + """Validates that supplied access token is registered and valid. + + :param client_key: The client/consumer key. + :param token: The access token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + Note that if the dummy access token is supplied it should validate in + the same or nearly the same amount of time as a valid one. + + Ensure latency inducing tasks are mimiced even for dummy clients. + For example, use:: + + from your_datastore import AccessToken + try: + return AccessToken.exists(client_key, access_token) + except DoesNotExist: + return False + + Rather than:: + + from your_datastore import AccessToken + if access_token == self.dummy_access_token: + return False + else: + return AccessToken.exists(client_key, access_token) + + This method is used by + + * ResourceEndpoint + """ + raise self._subclass_must_implement("validate_access_token") + + def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, + request, request_token=None, access_token=None): + """Validates that the nonce has not been used before. + + :param client_key: The client/consumer key. + :param timestamp: The ``oauth_timestamp`` parameter. + :param nonce: The ``oauth_nonce`` parameter. + :param request_token: Request token string, if any. + :param access_token: Access token string, if any. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + Per `Section 3.3`_ of the spec. + + "A nonce is a random string, uniquely generated by the client to allow + the server to verify that a request has never been made before and + helps prevent replay attacks when requests are made over a non-secure + channel. The nonce value MUST be unique across all requests with the + same timestamp, client credentials, and token combinations." + + .. _`Section 3.3`: https://tools.ietf.org/html/rfc5849#section-3.3 + + One of the first validation checks that will be made is for the validity + of the nonce and timestamp, which are associated with a client key and + possibly a token. If invalid then immediately fail the request + by returning False. If the nonce/timestamp pair has been used before and + you may just have detected a replay attack. Therefore it is an essential + part of OAuth security that you not allow nonce/timestamp reuse. + Note that this validation check is done before checking the validity of + the client and token.:: + + nonces_and_timestamps_database = [ + (u'foo', 1234567890, u'rannoMstrInghere', u'bar') + ] + + def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, + request_token=None, access_token=None): + + return ((client_key, timestamp, nonce, request_token or access_token) + not in self.nonces_and_timestamps_database) + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise self._subclass_must_implement("validate_timestamp_and_nonce") + + def validate_redirect_uri(self, client_key, redirect_uri, request): + """Validates the client supplied redirection URI. + + :param client_key: The client/consumer key. + :param redirect_uri: The URI the client which to redirect back to after + authorization is successful. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + It is highly recommended that OAuth providers require their clients + to register all redirection URIs prior to using them in requests and + register them as absolute URIs. See `CWE-601`_ for more information + about open redirection attacks. + + By requiring registration of all redirection URIs it should be + straightforward for the provider to verify whether the supplied + redirect_uri is valid or not. + + Alternatively per `Section 2.1`_ of the spec: + + "If the client is unable to receive callbacks or a callback URI has + been established via other means, the parameter value MUST be set to + "oob" (case sensitive), to indicate an out-of-band configuration." + + .. _`CWE-601`: http://cwe.mitre.org/top25/index.html#CWE-601 + .. _`Section 2.1`: https://tools.ietf.org/html/rfc5849#section-2.1 + + This method is used by + + * RequestTokenEndpoint + """ + raise self._subclass_must_implement("validate_redirect_uri") + + def validate_requested_realms(self, client_key, realms, request): + """Validates that the client may request access to the realm. + + :param client_key: The client/consumer key. + :param realms: The list of realms that client is requesting access to. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + This method is invoked when obtaining a request token and should + tie a realm to the request token and after user authorization + this realm restriction should transfer to the access token. + + This method is used by + + * RequestTokenEndpoint + """ + raise self._subclass_must_implement("validate_requested_realms") + + def validate_realms(self, client_key, token, request, uri=None, + realms=None): + """Validates access to the request realm. + + :param client_key: The client/consumer key. + :param token: A request token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param uri: The URI the realms is protecting. + :param realms: A list of realms that must have been granted to + the access token. + :returns: True or False + + How providers choose to use the realm parameter is outside the OAuth + specification but it is commonly used to restrict access to a subset + of protected resources such as "photos". + + realms is a convenience parameter which can be used to provide + a per view method pre-defined list of allowed realms. + + Can be as simple as:: + + from your_datastore import RequestToken + request_token = RequestToken.get(token, None) + + if not request_token: + return False + return set(request_token.realms).issuperset(set(realms)) + + This method is used by + + * ResourceEndpoint + """ + raise self._subclass_must_implement("validate_realms") + + def validate_verifier(self, client_key, token, verifier, request): + """Validates a verification code. + + :param client_key: The client/consumer key. + :param token: A request token string. + :param verifier: The authorization verifier string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + OAuth providers issue a verification code to clients after the + resource owner authorizes access. This code is used by the client to + obtain token credentials and the provider must verify that the + verifier is valid and associated with the client as well as the + resource owner. + + Verifier validation should be done in near constant time + (to avoid verifier enumeration). To achieve this we need a + constant time string comparison which is provided by OAuthLib + in ``oauthlib.common.safe_string_equals``:: + + from your_datastore import Verifier + correct_verifier = Verifier.get(client_key, request_token) + from oauthlib.common import safe_string_equals + return safe_string_equals(verifier, correct_verifier) + + This method is used by + + * AccessTokenEndpoint + """ + raise self._subclass_must_implement("validate_verifier") + + def verify_request_token(self, token, request): + """Verify that the given OAuth1 request token is valid. + + :param token: A request token string. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + This method is used only in AuthorizationEndpoint to check whether the + oauth_token given in the authorization URL is valid or not. + This request is not signed and thus similar ``validate_request_token`` + method can not be used. + + This method is used by + + * AuthorizationEndpoint + """ + raise self._subclass_must_implement("verify_request_token") + + def verify_realms(self, token, realms, request): + """Verify authorized realms to see if they match those given to token. + + :param token: An access token string. + :param realms: A list of realms the client attempts to access. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :returns: True or False + + This prevents the list of authorized realms sent by the client during + the authorization step to be altered to include realms outside what + was bound with the request token. + + Can be as simple as:: + + valid_realms = self.get_realms(token) + return all((r in valid_realms for r in realms)) + + This method is used by + + * AuthorizationEndpoint + """ + raise self._subclass_must_implement("verify_realms") + + def save_access_token(self, token, request): + """Save an OAuth1 access token. + + :param token: A dict with token credentials. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + The token dictionary will at minimum include + + * ``oauth_token`` the access token string. + * ``oauth_token_secret`` the token specific secret used in signing. + * ``oauth_authorized_realms`` a space separated list of realms. + + Client key can be obtained from ``request.client_key``. + + The list of realms (not joined string) can be obtained from + ``request.realm``. + + This method is used by + + * AccessTokenEndpoint + """ + raise self._subclass_must_implement("save_access_token") + + def save_request_token(self, token, request): + """Save an OAuth1 request token. + + :param token: A dict with token credentials. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + The token dictionary will at minimum include + + * ``oauth_token`` the request token string. + * ``oauth_token_secret`` the token specific secret used in signing. + * ``oauth_callback_confirmed`` the string ``true``. + + Client key can be obtained from ``request.client_key``. + + This method is used by + + * RequestTokenEndpoint + """ + raise self._subclass_must_implement("save_request_token") + + def save_verifier(self, token, verifier, request): + """Associate an authorization verifier with a request token. + + :param token: A request token string. + :param verifier: A dictionary containing the oauth_verifier and + oauth_token + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + We need to associate verifiers with tokens for validation during the + access token request. + + Note that unlike save_x_token token here is the ``oauth_token`` token + string from the request token saved previously. + + This method is used by + + * AuthorizationEndpoint + """ + raise self._subclass_must_implement("save_verifier") diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/signature.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/signature.py new file mode 100644 index 0000000000..9cb1a517ee --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/signature.py @@ -0,0 +1,852 @@ +""" +This module is an implementation of `section 3.4`_ of RFC 5849. + +**Usage** + +Steps for signing a request: + +1. Collect parameters from the request using ``collect_parameters``. +2. Normalize those parameters using ``normalize_parameters``. +3. Create the *base string URI* using ``base_string_uri``. +4. Create the *signature base string* from the above three components + using ``signature_base_string``. +5. Pass the *signature base string* and the client credentials to one of the + sign-with-client functions. The HMAC-based signing functions needs + client credentials with secrets. The RSA-based signing functions needs + client credentials with an RSA private key. + +To verify a request, pass the request and credentials to one of the verify +functions. The HMAC-based signing functions needs the shared secrets. The +RSA-based verify functions needs the RSA public key. + +**Scope** + +All of the functions in this module should be considered internal to OAuthLib, +since they are not imported into the "oauthlib.oauth1" module. Programs using +OAuthLib should not use directly invoke any of the functions in this module. + +**Deprecated functions** + +The "sign_" methods that are not "_with_client" have been deprecated. They may +be removed in a future release. Since they are all internal functions, this +should have no impact on properly behaving programs. + +.. _`section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 +""" + +import binascii +import hashlib +import hmac +import ipaddress +import logging +import urllib.parse as urlparse +import warnings + +from oauthlib.common import extract_params, safe_string_equals, urldecode + +from . import utils + +log = logging.getLogger(__name__) + + +# ==== Common functions ========================================== + +def signature_base_string( + http_method: str, + base_str_uri: str, + normalized_encoded_request_parameters: str) -> str: + """ + Construct the signature base string. + + The *signature base string* is the value that is calculated and signed by + the client. It is also independently calculated by the server to verify + the signature, and therefore must produce the exact same value at both + ends or the signature won't verify. + + The rules for calculating the *signature base string* are defined in + section 3.4.1.1`_ of RFC 5849. + + .. _`section 3.4.1.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.1 + """ + + # The signature base string is constructed by concatenating together, + # in order, the following HTTP request elements: + + # 1. The HTTP request method in uppercase. For example: "HEAD", + # "GET", "POST", etc. If the request uses a custom HTTP method, it + # MUST be encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + base_string = utils.escape(http_method.upper()) + + # 2. An "&" character (ASCII code 38). + base_string += '&' + + # 3. The base string URI from `Section 3.4.1.2`_, after being encoded + # (`Section 3.6`_). + # + # .. _`Section 3.4.1.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.2 + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + base_string += utils.escape(base_str_uri) + + # 4. An "&" character (ASCII code 38). + base_string += '&' + + # 5. The request parameters as normalized in `Section 3.4.1.3.2`_, after + # being encoded (`Section 3.6`). + # + # .. _`Sec 3.4.1.3.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + base_string += utils.escape(normalized_encoded_request_parameters) + + return base_string + + +def base_string_uri(uri: str, host: str = None) -> str: + """ + Calculates the _base string URI_. + + The *base string URI* is one of the components that make up the + *signature base string*. + + The ``host`` is optional. If provided, it is used to override any host and + port values in the ``uri``. The value for ``host`` is usually extracted from + the "Host" request header from the HTTP request. Its value may be just the + hostname, or the hostname followed by a colon and a TCP/IP port number + (hostname:port). If a value for the``host`` is provided but it does not + contain a port number, the default port number is used (i.e. if the ``uri`` + contained a port number, it will be discarded). + + The rules for calculating the *base string URI* are defined in + section 3.4.1.2`_ of RFC 5849. + + .. _`section 3.4.1.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.2 + + :param uri: URI + :param host: hostname with optional port number, separated by a colon + :return: base string URI + """ + + if not isinstance(uri, str): + raise ValueError('uri must be a string.') + + # FIXME: urlparse does not support unicode + output = urlparse.urlparse(uri) + scheme = output.scheme + hostname = output.hostname + port = output.port + path = output.path + params = output.params + + # The scheme, authority, and path of the request resource URI `RFC3986` + # are included by constructing an "http" or "https" URI representing + # the request resource (without the query or fragment) as follows: + # + # .. _`RFC3986`: https://tools.ietf.org/html/rfc3986 + + if not scheme: + raise ValueError('missing scheme') + + # Per `RFC 2616 section 5.1.2`_: + # + # Note that the absolute path cannot be empty; if none is present in + # the original URI, it MUST be given as "/" (the server root). + # + # .. _`RFC 2616 5.1.2`: https://tools.ietf.org/html/rfc2616#section-5.1.2 + if not path: + path = '/' + + # 1. The scheme and host MUST be in lowercase. + scheme = scheme.lower() + # Note: if ``host`` is used, it will be converted to lowercase below + if hostname is not None: + hostname = hostname.lower() + + # 2. The host and port values MUST match the content of the HTTP + # request "Host" header field. + if host is not None: + # NOTE: override value in uri with provided host + # Host argument is equal to netloc. It means it's missing scheme. + # Add it back, before parsing. + + host = host.lower() + host = f"{scheme}://{host}" + output = urlparse.urlparse(host) + hostname = output.hostname + port = output.port + + # 3. The port MUST be included if it is not the default port for the + # scheme, and MUST be excluded if it is the default. Specifically, + # the port MUST be excluded when making an HTTP request `RFC2616`_ + # to port 80 or when making an HTTPS request `RFC2818`_ to port 443. + # All other non-default port numbers MUST be included. + # + # .. _`RFC2616`: https://tools.ietf.org/html/rfc2616 + # .. _`RFC2818`: https://tools.ietf.org/html/rfc2818 + + if hostname is None: + raise ValueError('missing host') + + # NOTE: Try guessing if we're dealing with IP or hostname + try: + hostname = ipaddress.ip_address(hostname) + except ValueError: + pass + + if isinstance(hostname, ipaddress.IPv6Address): + hostname = f"[{hostname}]" + elif isinstance(hostname, ipaddress.IPv4Address): + hostname = f"{hostname}" + + if port is not None and not (0 < port <= 65535): + raise ValueError('port out of range') # 16-bit unsigned ints + if (scheme, port) in (('http', 80), ('https', 443)): + netloc = hostname # default port for scheme: exclude port num + elif port: + netloc = f"{hostname}:{port}" # use hostname:port + else: + netloc = hostname + + v = urlparse.urlunparse((scheme, netloc, path, params, '', '')) + + # RFC 5849 does not specify which characters are encoded in the + # "base string URI", nor how they are encoded - which is very bad, since + # the signatures won't match if there are any differences. Fortunately, + # most URIs only use characters that are clearly not encoded (e.g. digits + # and A-Z, a-z), so have avoided any differences between implementations. + # + # The example from its section 3.4.1.2 illustrates that spaces in + # the path are percent encoded. But it provides no guidance as to what other + # characters (if any) must be encoded (nor how); nor if characters in the + # other components are to be encoded or not. + # + # This implementation **assumes** that **only** the space is percent-encoded + # and it is done to the entire value (not just to spaces in the path). + # + # This code may need to be changed if it is discovered that other characters + # are expected to be encoded. + # + # Note: the "base string URI" returned by this function will be encoded + # again before being concatenated into the "signature base string". So any + # spaces in the URI will actually appear in the "signature base string" + # as "%2520" (the "%20" further encoded according to section 3.6). + + return v.replace(' ', '%20') + + +def collect_parameters(uri_query='', body=None, headers=None, + exclude_oauth_signature=True, with_realm=False): + """ + Gather the request parameters from all the parameter sources. + + This function is used to extract all the parameters, which are then passed + to ``normalize_parameters`` to produce one of the components that make up + the *signature base string*. + + Parameters starting with `oauth_` will be unescaped. + + Body parameters must be supplied as a dict, a list of 2-tuples, or a + form encoded query string. + + Headers must be supplied as a dict. + + The rules where the parameters must be sourced from are defined in + `section 3.4.1.3.1`_ of RFC 5849. + + .. _`Sec 3.4.1.3.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.1 + """ + if body is None: + body = [] + headers = headers or {} + params = [] + + # The parameters from the following sources are collected into a single + # list of name/value pairs: + + # * The query component of the HTTP request URI as defined by + # `RFC3986, Section 3.4`_. The query component is parsed into a list + # of name/value pairs by treating it as an + # "application/x-www-form-urlencoded" string, separating the names + # and values and decoding them as defined by W3C.REC-html40-19980424 + # `W3C-HTML-4.0`_, Section 17.13.4. + # + # .. _`RFC3986, Sec 3.4`: https://tools.ietf.org/html/rfc3986#section-3.4 + # .. _`W3C-HTML-4.0`: https://www.w3.org/TR/1998/REC-html40-19980424/ + if uri_query: + params.extend(urldecode(uri_query)) + + # * The OAuth HTTP "Authorization" header field (`Section 3.5.1`_) if + # present. The header's content is parsed into a list of name/value + # pairs excluding the "realm" parameter if present. The parameter + # values are decoded as defined by `Section 3.5.1`_. + # + # .. _`Section 3.5.1`: https://tools.ietf.org/html/rfc5849#section-3.5.1 + if headers: + headers_lower = {k.lower(): v for k, v in headers.items()} + authorization_header = headers_lower.get('authorization') + if authorization_header is not None: + params.extend([i for i in utils.parse_authorization_header( + authorization_header) if with_realm or i[0] != 'realm']) + + # * The HTTP request entity-body, but only if all of the following + # conditions are met: + # * The entity-body is single-part. + # + # * The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # W3C.REC-html40-19980424 `W3C-HTML-4.0`_. + + # * The HTTP request entity-header includes the "Content-Type" + # header field set to "application/x-www-form-urlencoded". + # + # .. _`W3C-HTML-4.0`: https://www.w3.org/TR/1998/REC-html40-19980424/ + + # TODO: enforce header param inclusion conditions + bodyparams = extract_params(body) or [] + params.extend(bodyparams) + + # ensure all oauth params are unescaped + unescaped_params = [] + for k, v in params: + if k.startswith('oauth_'): + v = utils.unescape(v) + unescaped_params.append((k, v)) + + # The "oauth_signature" parameter MUST be excluded from the signature + # base string if present. + if exclude_oauth_signature: + unescaped_params = list(filter(lambda i: i[0] != 'oauth_signature', + unescaped_params)) + + return unescaped_params + + +def normalize_parameters(params) -> str: + """ + Calculate the normalized request parameters. + + The *normalized request parameters* is one of the components that make up + the *signature base string*. + + The rules for parameter normalization are defined in `section 3.4.1.3.2`_ of + RFC 5849. + + .. _`Sec 3.4.1.3.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + """ + + # The parameters collected in `Section 3.4.1.3`_ are normalized into a + # single string as follows: + # + # .. _`Section 3.4.1.3`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3 + + # 1. First, the name and value of each parameter are encoded + # (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + key_values = [(utils.escape(k), utils.escape(v)) for k, v in params] + + # 2. The parameters are sorted by name, using ascending byte value + # ordering. If two or more parameters share the same name, they + # are sorted by their value. + key_values.sort() + + # 3. The name of each parameter is concatenated to its corresponding + # value using an "=" character (ASCII code 61) as a separator, even + # if the value is empty. + parameter_parts = ['{}={}'.format(k, v) for k, v in key_values] + + # 4. The sorted name/value pairs are concatenated together into a + # single string by using an "&" character (ASCII code 38) as + # separator. + return '&'.join(parameter_parts) + + +# ==== Common functions for HMAC-based signature methods ========= + +def _sign_hmac(hash_algorithm_name: str, + sig_base_str: str, + client_secret: str, + resource_owner_secret: str): + """ + **HMAC-SHA256** + + The "HMAC-SHA256" signature method uses the HMAC-SHA256 signature + algorithm as defined in `RFC4634`_:: + + digest = HMAC-SHA256 (key, text) + + Per `section 3.4.2`_ of the spec. + + .. _`RFC4634`: https://tools.ietf.org/html/rfc4634 + .. _`section 3.4.2`: https://tools.ietf.org/html/rfc5849#section-3.4.2 + """ + + # The HMAC-SHA256 function variables are used in following way: + + # text is set to the value of the signature base string from + # `Section 3.4.1.1`_. + # + # .. _`Section 3.4.1.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.1 + text = sig_base_str + + # key is set to the concatenated values of: + # 1. The client shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + key = utils.escape(client_secret or '') + + # 2. An "&" character (ASCII code 38), which MUST be included + # even when either secret is empty. + key += '&' + + # 3. The token shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + key += utils.escape(resource_owner_secret or '') + + # Get the hashing algorithm to use + + m = { + 'SHA-1': hashlib.sha1, + 'SHA-256': hashlib.sha256, + 'SHA-512': hashlib.sha512, + } + hash_alg = m[hash_algorithm_name] + + # Calculate the signature + + # FIXME: HMAC does not support unicode! + key_utf8 = key.encode('utf-8') + text_utf8 = text.encode('utf-8') + signature = hmac.new(key_utf8, text_utf8, hash_alg) + + # digest is used to set the value of the "oauth_signature" protocol + # parameter, after the result octet string is base64-encoded + # per `RFC2045, Section 6.8`. + # + # .. _`RFC2045, Sec 6.8`: https://tools.ietf.org/html/rfc2045#section-6.8 + return binascii.b2a_base64(signature.digest())[:-1].decode('utf-8') + + +def _verify_hmac(hash_algorithm_name: str, + request, + client_secret=None, + resource_owner_secret=None): + """Verify a HMAC-SHA1 signature. + + Per `section 3.4`_ of the spec. + + .. _`section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 + + To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri + attribute MUST be an absolute URI whose netloc part identifies the + origin server or gateway on which the resource resides. Any Host + item of the request argument's headers dict attribute will be + ignored. + + .. _`RFC2616 section 5.2`: https://tools.ietf.org/html/rfc2616#section-5.2 + + """ + norm_params = normalize_parameters(request.params) + bs_uri = base_string_uri(request.uri) + sig_base_str = signature_base_string(request.http_method, bs_uri, + norm_params) + signature = _sign_hmac(hash_algorithm_name, sig_base_str, + client_secret, resource_owner_secret) + match = safe_string_equals(signature, request.signature) + if not match: + log.debug('Verify HMAC failed: signature base string: %s', sig_base_str) + return match + + +# ==== HMAC-SHA1 ================================================= + +def sign_hmac_sha1_with_client(sig_base_str, client): + return _sign_hmac('SHA-1', sig_base_str, + client.client_secret, client.resource_owner_secret) + + +def verify_hmac_sha1(request, client_secret=None, resource_owner_secret=None): + return _verify_hmac('SHA-1', request, client_secret, resource_owner_secret) + + +def sign_hmac_sha1(base_string, client_secret, resource_owner_secret): + """ + Deprecated function for calculating a HMAC-SHA1 signature. + + This function has been replaced by invoking ``sign_hmac`` with "SHA-1" + as the hash algorithm name. + + This function was invoked by sign_hmac_sha1_with_client and + test_signatures.py, but does any application invoke it directly? If not, + it can be removed. + """ + warnings.warn('use sign_hmac_sha1_with_client instead of sign_hmac_sha1', + DeprecationWarning) + + # For some unknown reason, the original implementation assumed base_string + # could either be bytes or str. The signature base string calculating + # function always returned a str, so the new ``sign_rsa`` only expects that. + + base_string = base_string.decode('ascii') \ + if isinstance(base_string, bytes) else base_string + + return _sign_hmac('SHA-1', base_string, + client_secret, resource_owner_secret) + + +# ==== HMAC-SHA256 =============================================== + +def sign_hmac_sha256_with_client(sig_base_str, client): + return _sign_hmac('SHA-256', sig_base_str, + client.client_secret, client.resource_owner_secret) + + +def verify_hmac_sha256(request, client_secret=None, resource_owner_secret=None): + return _verify_hmac('SHA-256', request, + client_secret, resource_owner_secret) + + +def sign_hmac_sha256(base_string, client_secret, resource_owner_secret): + """ + Deprecated function for calculating a HMAC-SHA256 signature. + + This function has been replaced by invoking ``sign_hmac`` with "SHA-256" + as the hash algorithm name. + + This function was invoked by sign_hmac_sha256_with_client and + test_signatures.py, but does any application invoke it directly? If not, + it can be removed. + """ + warnings.warn( + 'use sign_hmac_sha256_with_client instead of sign_hmac_sha256', + DeprecationWarning) + + # For some unknown reason, the original implementation assumed base_string + # could either be bytes or str. The signature base string calculating + # function always returned a str, so the new ``sign_rsa`` only expects that. + + base_string = base_string.decode('ascii') \ + if isinstance(base_string, bytes) else base_string + + return _sign_hmac('SHA-256', base_string, + client_secret, resource_owner_secret) + + +# ==== HMAC-SHA512 =============================================== + +def sign_hmac_sha512_with_client(sig_base_str: str, + client): + return _sign_hmac('SHA-512', sig_base_str, + client.client_secret, client.resource_owner_secret) + + +def verify_hmac_sha512(request, + client_secret: str = None, + resource_owner_secret: str = None): + return _verify_hmac('SHA-512', request, + client_secret, resource_owner_secret) + + +# ==== Common functions for RSA-based signature methods ========== + +_jwt_rsa = {} # cache of RSA-hash implementations from PyJWT jwt.algorithms + + +def _get_jwt_rsa_algorithm(hash_algorithm_name: str): + """ + Obtains an RSAAlgorithm object that implements RSA with the hash algorithm. + + This method maintains the ``_jwt_rsa`` cache. + + Returns a jwt.algorithm.RSAAlgorithm. + """ + if hash_algorithm_name in _jwt_rsa: + # Found in cache: return it + return _jwt_rsa[hash_algorithm_name] + else: + # Not in cache: instantiate a new RSAAlgorithm + + # PyJWT has some nice pycrypto/cryptography abstractions + import jwt.algorithms as jwt_algorithms + m = { + 'SHA-1': jwt_algorithms.hashes.SHA1, + 'SHA-256': jwt_algorithms.hashes.SHA256, + 'SHA-512': jwt_algorithms.hashes.SHA512, + } + v = jwt_algorithms.RSAAlgorithm(m[hash_algorithm_name]) + + _jwt_rsa[hash_algorithm_name] = v # populate cache + + return v + + +def _prepare_key_plus(alg, keystr): + """ + Prepare a PEM encoded key (public or private), by invoking the `prepare_key` + method on alg with the keystr. + + The keystr should be a string or bytes. If the keystr is bytes, it is + decoded as UTF-8 before being passed to prepare_key. Otherwise, it + is passed directly. + """ + if isinstance(keystr, bytes): + keystr = keystr.decode('utf-8') + return alg.prepare_key(keystr) + + +def _sign_rsa(hash_algorithm_name: str, + sig_base_str: str, + rsa_private_key: str): + """ + Calculate the signature for an RSA-based signature method. + + The ``alg`` is used to calculate the digest over the signature base string. + For the "RSA_SHA1" signature method, the alg must be SHA-1. While OAuth 1.0a + only defines the RSA-SHA1 signature method, this function can be used for + other non-standard signature methods that only differ from RSA-SHA1 by the + digest algorithm. + + Signing for the RSA-SHA1 signature method is defined in + `section 3.4.3`_ of RFC 5849. + + The RSASSA-PKCS1-v1_5 signature algorithm used defined by + `RFC3447, Section 8.2`_ (also known as PKCS#1), with the `alg` as the + hash function for EMSA-PKCS1-v1_5. To + use this method, the client MUST have established client credentials + with the server that included its RSA public key (in a manner that is + beyond the scope of this specification). + + .. _`section 3.4.3`: https://tools.ietf.org/html/rfc5849#section-3.4.3 + .. _`RFC3447, Section 8.2`: https://tools.ietf.org/html/rfc3447#section-8.2 + """ + + # Get the implementation of RSA-hash + + alg = _get_jwt_rsa_algorithm(hash_algorithm_name) + + # Check private key + + if not rsa_private_key: + raise ValueError('rsa_private_key required for RSA with ' + + alg.hash_alg.name + ' signature method') + + # Convert the "signature base string" into a sequence of bytes (M) + # + # The signature base string, by definition, only contain printable US-ASCII + # characters. So encoding it as 'ascii' will always work. It will raise a + # ``UnicodeError`` if it can't encode the value, which will never happen + # if the signature base string was created correctly. Therefore, using + # 'ascii' encoding provides an extra level of error checking. + + m = sig_base_str.encode('ascii') + + # Perform signing: S = RSASSA-PKCS1-V1_5-SIGN (K, M) + + key = _prepare_key_plus(alg, rsa_private_key) + s = alg.sign(m, key) + + # base64-encoded per RFC2045 section 6.8. + # + # 1. While b2a_base64 implements base64 defined by RFC 3548. As used here, + # it is the same as base64 defined by RFC 2045. + # 2. b2a_base64 includes a "\n" at the end of its result ([:-1] removes it) + # 3. b2a_base64 produces a binary string. Use decode to produce a str. + # It should only contain only printable US-ASCII characters. + + return binascii.b2a_base64(s)[:-1].decode('ascii') + + +def _verify_rsa(hash_algorithm_name: str, + request, + rsa_public_key: str): + """ + Verify a base64 encoded signature for a RSA-based signature method. + + The ``alg`` is used to calculate the digest over the signature base string. + For the "RSA_SHA1" signature method, the alg must be SHA-1. While OAuth 1.0a + only defines the RSA-SHA1 signature method, this function can be used for + other non-standard signature methods that only differ from RSA-SHA1 by the + digest algorithm. + + Verification for the RSA-SHA1 signature method is defined in + `section 3.4.3`_ of RFC 5849. + + .. _`section 3.4.3`: https://tools.ietf.org/html/rfc5849#section-3.4.3 + + To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri + attribute MUST be an absolute URI whose netloc part identifies the + origin server or gateway on which the resource resides. Any Host + item of the request argument's headers dict attribute will be + ignored. + + .. _`RFC2616 Sec 5.2`: https://tools.ietf.org/html/rfc2616#section-5.2 + """ + + try: + # Calculate the *signature base string* of the actual received request + + norm_params = normalize_parameters(request.params) + bs_uri = base_string_uri(request.uri) + sig_base_str = signature_base_string( + request.http_method, bs_uri, norm_params) + + # Obtain the signature that was received in the request + + sig = binascii.a2b_base64(request.signature.encode('ascii')) + + # Get the implementation of RSA-with-hash algorithm to use + + alg = _get_jwt_rsa_algorithm(hash_algorithm_name) + + # Verify the received signature was produced by the private key + # corresponding to the `rsa_public_key`, signing exact same + # *signature base string*. + # + # RSASSA-PKCS1-V1_5-VERIFY ((n, e), M, S) + + key = _prepare_key_plus(alg, rsa_public_key) + + # The signature base string only contain printable US-ASCII characters. + # The ``encode`` method with the default "strict" error handling will + # raise a ``UnicodeError`` if it can't encode the value. So using + # "ascii" will always work. + + verify_ok = alg.verify(sig_base_str.encode('ascii'), key, sig) + + if not verify_ok: + log.debug('Verify failed: RSA with ' + alg.hash_alg.name + + ': signature base string=%s' + sig_base_str) + return verify_ok + + except UnicodeError: + # A properly encoded signature will only contain printable US-ASCII + # characters. The ``encode`` method with the default "strict" error + # handling will raise a ``UnicodeError`` if it can't decode the value. + # So using "ascii" will work with all valid signatures. But an + # incorrectly or maliciously produced signature could contain other + # bytes. + # + # This implementation treats that situation as equivalent to the + # signature verification having failed. + # + # Note: simply changing the encode to use 'utf-8' will not remove this + # case, since an incorrect or malicious request can contain bytes which + # are invalid as UTF-8. + return False + + +# ==== RSA-SHA1 ================================================== + +def sign_rsa_sha1_with_client(sig_base_str, client): + # For some reason, this function originally accepts both str and bytes. + # This behaviour is preserved here. But won't be done for the newer + # sign_rsa_sha256_with_client and sign_rsa_sha512_with_client functions, + # which will only accept strings. The function to calculate a + # "signature base string" always produces a string, so it is not clear + # why support for bytes would ever be needed. + sig_base_str = sig_base_str.decode('ascii')\ + if isinstance(sig_base_str, bytes) else sig_base_str + + return _sign_rsa('SHA-1', sig_base_str, client.rsa_key) + + +def verify_rsa_sha1(request, rsa_public_key: str): + return _verify_rsa('SHA-1', request, rsa_public_key) + + +def sign_rsa_sha1(base_string, rsa_private_key): + """ + Deprecated function for calculating a RSA-SHA1 signature. + + This function has been replaced by invoking ``sign_rsa`` with "SHA-1" + as the hash algorithm name. + + This function was invoked by sign_rsa_sha1_with_client and + test_signatures.py, but does any application invoke it directly? If not, + it can be removed. + """ + warnings.warn('use _sign_rsa("SHA-1", ...) instead of sign_rsa_sha1', + DeprecationWarning) + + if isinstance(base_string, bytes): + base_string = base_string.decode('ascii') + + return _sign_rsa('SHA-1', base_string, rsa_private_key) + + +# ==== RSA-SHA256 ================================================ + +def sign_rsa_sha256_with_client(sig_base_str: str, client): + return _sign_rsa('SHA-256', sig_base_str, client.rsa_key) + + +def verify_rsa_sha256(request, rsa_public_key: str): + return _verify_rsa('SHA-256', request, rsa_public_key) + + +# ==== RSA-SHA512 ================================================ + +def sign_rsa_sha512_with_client(sig_base_str: str, client): + return _sign_rsa('SHA-512', sig_base_str, client.rsa_key) + + +def verify_rsa_sha512(request, rsa_public_key: str): + return _verify_rsa('SHA-512', request, rsa_public_key) + + +# ==== PLAINTEXT ================================================= + +def sign_plaintext_with_client(_signature_base_string, client): + # _signature_base_string is not used because the signature with PLAINTEXT + # is just the secret: it isn't a real signature. + return sign_plaintext(client.client_secret, client.resource_owner_secret) + + +def sign_plaintext(client_secret, resource_owner_secret): + """Sign a request using plaintext. + + Per `section 3.4.4`_ of the spec. + + The "PLAINTEXT" method does not employ a signature algorithm. It + MUST be used with a transport-layer mechanism such as TLS or SSL (or + sent over a secure channel with equivalent protections). It does not + utilize the signature base string or the "oauth_timestamp" and + "oauth_nonce" parameters. + + .. _`section 3.4.4`: https://tools.ietf.org/html/rfc5849#section-3.4.4 + + """ + + # The "oauth_signature" protocol parameter is set to the concatenated + # value of: + + # 1. The client shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + signature = utils.escape(client_secret or '') + + # 2. An "&" character (ASCII code 38), which MUST be included even + # when either secret is empty. + signature += '&' + + # 3. The token shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + signature += utils.escape(resource_owner_secret or '') + + return signature + + +def verify_plaintext(request, client_secret=None, resource_owner_secret=None): + """Verify a PLAINTEXT signature. + + Per `section 3.4`_ of the spec. + + .. _`section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 + """ + signature = sign_plaintext(client_secret, resource_owner_secret) + match = safe_string_equals(signature, request.signature) + if not match: + log.debug('Verify PLAINTEXT failed') + return match diff --git a/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/utils.py b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/utils.py new file mode 100644 index 0000000000..8fb8302e30 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth1/rfc5849/utils.py @@ -0,0 +1,83 @@ +""" +oauthlib.utils +~~~~~~~~~~~~~~ + +This module contains utility methods used by various parts of the OAuth +spec. +""" +import urllib.request as urllib2 + +from oauthlib.common import quote, unquote + +UNICODE_ASCII_CHARACTER_SET = ('abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '0123456789') + + +def filter_params(target): + """Decorator which filters params to remove non-oauth_* parameters + + Assumes the decorated method takes a params dict or list of tuples as its + first argument. + """ + def wrapper(params, *args, **kwargs): + params = filter_oauth_params(params) + return target(params, *args, **kwargs) + + wrapper.__doc__ = target.__doc__ + return wrapper + + +def filter_oauth_params(params): + """Removes all non oauth parameters from a dict or a list of params.""" + is_oauth = lambda kv: kv[0].startswith("oauth_") + if isinstance(params, dict): + return list(filter(is_oauth, list(params.items()))) + else: + return list(filter(is_oauth, params)) + + +def escape(u): + """Escape a unicode string in an OAuth-compatible fashion. + + Per `section 3.6`_ of the spec. + + .. _`section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + + """ + if not isinstance(u, str): + raise ValueError('Only unicode objects are escapable. ' + + 'Got {!r} of type {}.'.format(u, type(u))) + # Letters, digits, and the characters '_.-' are already treated as safe + # by urllib.quote(). We need to add '~' to fully support rfc5849. + return quote(u, safe=b'~') + + +def unescape(u): + if not isinstance(u, str): + raise ValueError('Only unicode objects are unescapable.') + return unquote(u) + + +def parse_keqv_list(l): + """A unicode-safe version of urllib2.parse_keqv_list""" + # With Python 2.6, parse_http_list handles unicode fine + return urllib2.parse_keqv_list(l) + + +def parse_http_list(u): + """A unicode-safe version of urllib2.parse_http_list""" + # With Python 2.6, parse_http_list handles unicode fine + return urllib2.parse_http_list(u) + + +def parse_authorization_header(authorization_header): + """Parse an OAuth authorization header into a list of 2-tuples""" + auth_scheme = 'OAuth '.lower() + if authorization_header[:len(auth_scheme)].lower().startswith(auth_scheme): + items = parse_http_list(authorization_header[len(auth_scheme):]) + try: + return list(parse_keqv_list(items).items()) + except (IndexError, ValueError): + pass + raise ValueError('Malformed authorization header') diff --git a/contrib/python/oauthlib/oauthlib/oauth2/__init__.py b/contrib/python/oauthlib/oauthlib/oauth2/__init__.py new file mode 100644 index 0000000000..deefb1af78 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/__init__.py @@ -0,0 +1,36 @@ +""" +oauthlib.oauth2 +~~~~~~~~~~~~~~ + +This module is a wrapper for the most recent implementation of OAuth 2.0 Client +and Server classes. +""" +from .rfc6749.clients import ( + BackendApplicationClient, Client, LegacyApplicationClient, + MobileApplicationClient, ServiceApplicationClient, WebApplicationClient, +) +from .rfc6749.endpoints import ( + AuthorizationEndpoint, BackendApplicationServer, IntrospectEndpoint, + LegacyApplicationServer, MetadataEndpoint, MobileApplicationServer, + ResourceEndpoint, RevocationEndpoint, Server, TokenEndpoint, + WebApplicationServer, +) +from .rfc6749.errors import ( + AccessDeniedError, FatalClientError, InsecureTransportError, + InvalidClientError, InvalidClientIdError, InvalidGrantError, + InvalidRedirectURIError, InvalidRequestError, InvalidRequestFatalError, + InvalidScopeError, MismatchingRedirectURIError, MismatchingStateError, + MissingClientIdError, MissingCodeError, MissingRedirectURIError, + MissingResponseTypeError, MissingTokenError, MissingTokenTypeError, + OAuth2Error, ServerError, TemporarilyUnavailableError, TokenExpiredError, + UnauthorizedClientError, UnsupportedGrantTypeError, + UnsupportedResponseTypeError, UnsupportedTokenTypeError, +) +from .rfc6749.grant_types import ( + AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, + RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, +) +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/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/__init__.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/__init__.py new file mode 100644 index 0000000000..4b75a8a196 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/__init__.py @@ -0,0 +1,16 @@ +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +import functools +import logging + +from .endpoints.base import BaseEndpoint, catch_errors_and_unavailability +from .errors import ( + FatalClientError, OAuth2Error, ServerError, TemporarilyUnavailableError, +) + +log = logging.getLogger(__name__) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/__init__.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/__init__.py new file mode 100644 index 0000000000..8fc6c955a2 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming OAuth 2.0 RFC6749. +""" +from .backend_application import BackendApplicationClient +from .base import AUTH_HEADER, BODY, URI_QUERY, Client +from .legacy_application import LegacyApplicationClient +from .mobile_application import MobileApplicationClient +from .service_application import ServiceApplicationClient +from .web_application import WebApplicationClient diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/backend_application.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/backend_application.py new file mode 100644 index 0000000000..e11e8fae38 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/backend_application.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from ..parameters import prepare_token_request +from .base import Client + + +class BackendApplicationClient(Client): + + """A public client utilizing the client credentials grant workflow. + + The client can request an access token using only its client + credentials (or other supported means of authentication) when the + client is requesting access to the protected resources under its + control, or those of another resource owner which has been previously + arranged with the authorization server (the method of which is beyond + the scope of this specification). + + The client credentials grant type MUST only be used by confidential + clients. + + Since the client authentication is used as the authorization grant, + no additional authorization request is needed. + """ + + grant_type = 'client_credentials' + + def prepare_request_body(self, body='', scope=None, + include_client_id=False, **kwargs): + """Add the client credentials to the request body. + + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format per `Appendix B`_ in the HTTP request entity-body: + + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra parameters. 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 client MUST authenticate with the authorization server as + described in `Section 3.2.1`_. + + The prepared body will include all provided credentials as well as + the ``grant_type`` parameter set to ``client_credentials``:: + + >>> from oauthlib.oauth2 import BackendApplicationClient + >>> client = BackendApplicationClient('your_id') + >>> client.prepare_request_body(scope=['hello', 'world']) + 'grant_type=client_credentials&scope=hello+world' + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + 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, + scope=scope, **kwargs) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/base.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/base.py new file mode 100644 index 0000000000..d5eb0cc15f --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/base.py @@ -0,0 +1,604 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming OAuth 2.0 RFC6749. +""" +import base64 +import hashlib +import re +import secrets +import time +import warnings + +from oauthlib.common import generate_token +from oauthlib.oauth2.rfc6749 import tokens +from oauthlib.oauth2.rfc6749.errors import ( + InsecureTransportError, TokenExpiredError, +) +from oauthlib.oauth2.rfc6749.parameters import ( + parse_token_response, prepare_token_request, + prepare_token_revocation_request, +) +from oauthlib.oauth2.rfc6749.utils import is_secure_transport + +AUTH_HEADER = 'auth_header' +URI_QUERY = 'query' +BODY = 'body' + +FORM_ENC_HEADERS = { + 'Content-Type': 'application/x-www-form-urlencoded' +} + + +class Client: + """Base OAuth2 client responsible for access token management. + + This class also acts as a generic interface providing methods common to all + client types such as ``prepare_authorization_request`` and + ``prepare_token_revocation_request``. The ``prepare_x_request`` methods are + the recommended way of interacting with clients (as opposed to the abstract + prepare uri/body/etc methods). They are recommended over the older set + because they are easier to use (more consistent) and add a few additional + security checks, such as HTTPS and state checking. + + Some of these methods require further implementation only provided by the + specific purpose clients such as + :py:class:`oauthlib.oauth2.MobileApplicationClient` and thus you should always + seek to use the client class matching the OAuth workflow you need. For + Python, this is usually :py:class:`oauthlib.oauth2.WebApplicationClient`. + + """ + refresh_token_key = 'refresh_token' + + def __init__(self, client_id, + default_token_placement=AUTH_HEADER, + token_type='Bearer', + access_token=None, + refresh_token=None, + mac_key=None, + mac_algorithm=None, + token=None, + scope=None, + 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. + + :param client_id: Client identifier given by the OAuth provider upon + registration. + + :param default_token_placement: Tokens can be supplied in the Authorization + header (default), the URL query component (``query``) or the request + body (``body``). + + :param token_type: OAuth 2 token type. Defaults to Bearer. Change this + if you specify the ``access_token`` parameter and know it is of a + different token type, such as a MAC, JWT or SAML token. Can + also be supplied as ``token_type`` inside the ``token`` dict parameter. + + :param access_token: An access token (string) used to authenticate + requests to protected resources. Can also be supplied inside the + ``token`` dict parameter. + + :param refresh_token: A refresh token (string) used to refresh expired + tokens. Can also be supplied inside the ``token`` dict parameter. + + :param mac_key: Encryption key used with MAC tokens. + + :param mac_algorithm: Hashing algorithm for MAC tokens. + + :param token: A dict of token attributes such as ``access_token``, + ``token_type`` and ``expires_at``. + + :param scope: A list of default scopes to request authorization for. + + :param state: A CSRF protection string used during authorization. + + :param redirect_url: The redirection endpoint on the client side to which + the user returns after authorization. + + :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 + self.default_token_placement = default_token_placement + self.token_type = token_type + self.access_token = access_token + self.refresh_token = refresh_token + self.mac_key = mac_key + self.mac_algorithm = mac_algorithm + self.token = token or {} + self.scope = scope + 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 + self.populate_token_attributes(self.token) + + @property + def token_types(self): + """Supported token types and their respective methods + + Additional tokens can be supported by extending this dictionary. + + The Bearer token spec is stable and safe to use. + + The MAC token spec is not yet stable and support for MAC tokens + is experimental and currently matching version 00 of the spec. + """ + return { + 'Bearer': self._add_bearer_token, + 'MAC': self._add_mac_token + } + + def prepare_request_uri(self, *args, **kwargs): + """Abstract method used to create request URIs.""" + raise NotImplementedError("Must be implemented by inheriting classes.") + + def prepare_request_body(self, *args, **kwargs): + """Abstract method used to create request bodies.""" + raise NotImplementedError("Must be implemented by inheriting classes.") + + def parse_request_uri_response(self, *args, **kwargs): + """Abstract method used to parse redirection responses.""" + raise NotImplementedError("Must be implemented by inheriting classes.") + + def add_token(self, uri, http_method='GET', body=None, headers=None, + token_placement=None, **kwargs): + """Add token to the request uri, body or authorization header. + + The access token type provides the client with the information + required to successfully utilize the access token to make a protected + resource request (along with type-specific attributes). The client + MUST NOT use an access token if it does not understand the token + type. + + For example, the "bearer" token type defined in + [`I-D.ietf-oauth-v2-bearer`_] is utilized by simply including the access + token string in the request: + + .. code-block:: http + + GET /resource/1 HTTP/1.1 + Host: example.com + Authorization: Bearer mF_9.B5f-4.1JqM + + while the "mac" token type defined in [`I-D.ietf-oauth-v2-http-mac`_] is + utilized by issuing a MAC key together with the access token which is + used to sign certain components of the HTTP requests: + + .. code-block:: http + + GET /resource/1 HTTP/1.1 + Host: example.com + Authorization: MAC id="h480djs93hd8", + nonce="274312:dj83hs9s", + mac="kDZvddkndxvhGRXZhvuDjEWhGeE=" + + .. _`I-D.ietf-oauth-v2-bearer`: https://tools.ietf.org/html/rfc6749#section-12.2 + .. _`I-D.ietf-oauth-v2-http-mac`: https://tools.ietf.org/html/rfc6749#section-12.2 + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + token_placement = token_placement or self.default_token_placement + + case_insensitive_token_types = { + k.lower(): v for k, v in self.token_types.items()} + if not self.token_type.lower() in case_insensitive_token_types: + raise ValueError("Unsupported token type: %s" % self.token_type) + + if not (self.access_token or self.token.get('access_token')): + raise ValueError("Missing access token.") + + if self._expires_at and self._expires_at < time.time(): + raise TokenExpiredError() + + return case_insensitive_token_types[self.token_type.lower()](uri, http_method, body, + headers, token_placement, **kwargs) + + def prepare_authorization_request(self, authorization_url, state=None, + redirect_url=None, scope=None, **kwargs): + """Prepare the authorization request. + + This is the first step in many OAuth flows in which the user is + redirected to a certain authorization URL. This method adds + required parameters to the authorization URL. + + :param authorization_url: Provider authorization endpoint URL. + :param state: CSRF protection string. Will be automatically created if + not provided. The generated state is available via the ``state`` + attribute. Clients should verify that the state is unchanged and + present in the authorization response. This verification is done + automatically if using the ``authorization_response`` parameter + with ``prepare_token_request``. + :param redirect_url: Redirect URL to which the user will be returned + after authorization. Must be provided unless previously setup with + the provider. If provided then it must also be provided in the + token request. + :param scope: List of scopes to request. Must be equal to + or a subset of the scopes granted when obtaining the refresh + token. If none is provided, the ones provided in the constructor are + used. + :param kwargs: Additional parameters to included in the request. + :returns: The prepared request tuple with (url, headers, body). + """ + if not is_secure_transport(authorization_url): + raise InsecureTransportError() + + self.state = state or self.state_generator() + self.redirect_url = redirect_url or self.redirect_url + # do not assign scope to self automatically anymore + scope = self.scope if scope is None else scope + auth_url = self.prepare_request_uri( + authorization_url, redirect_uri=self.redirect_url, + scope=scope, state=self.state, **kwargs) + return auth_url, FORM_ENC_HEADERS, '' + + def prepare_token_request(self, token_url, authorization_response=None, + redirect_url=None, state=None, body='', **kwargs): + """Prepare a token creation request. + + Note that these requests usually require client authentication, either + by including client_id or a set of provider specific authentication + credentials. + + :param token_url: Provider token creation endpoint URL. + :param authorization_response: The full redirection URL string, i.e. + the location to which the user was redirected after successful + authorization. Used to mine credentials needed to obtain a token + in this step, such as authorization code. + :param redirect_url: The redirect_url supplied with the authorization + request (if there was one). + :param state: + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra parameters. Default ''. + :param kwargs: Additional parameters to included in the request. + :returns: The prepared request tuple with (url, headers, body). + """ + if not is_secure_transport(token_url): + raise InsecureTransportError() + + state = state or self.state + if authorization_response: + self.parse_request_uri_response( + authorization_response, state=state) + self.redirect_url = redirect_url or self.redirect_url + body = self.prepare_request_body(body=body, + redirect_uri=self.redirect_url, **kwargs) + + return token_url, FORM_ENC_HEADERS, body + + def prepare_refresh_token_request(self, token_url, refresh_token=None, + body='', scope=None, **kwargs): + """Prepare an access token refresh request. + + Expired access tokens can be replaced by new access tokens without + going through the OAuth dance if the client obtained a refresh token. + This refresh token and authentication credentials can be used to + obtain a new access token, and possibly a new refresh token. + + :param token_url: Provider token refresh endpoint URL. + :param refresh_token: Refresh token string. + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra parameters. Default ''. + :param scope: List of scopes to request. Must be equal to + or a subset of the scopes granted when obtaining the refresh + token. If none is provided, the ones provided in the constructor are + used. + :param kwargs: Additional parameters to included in the request. + :returns: The prepared request tuple with (url, headers, body). + """ + if not is_secure_transport(token_url): + raise InsecureTransportError() + + # do not assign scope to self automatically anymore + scope = self.scope if scope is None else scope + body = self.prepare_refresh_body(body=body, + refresh_token=refresh_token, scope=scope, **kwargs) + return token_url, FORM_ENC_HEADERS, body + + def prepare_token_revocation_request(self, revocation_url, token, + token_type_hint="access_token", body='', callback=None, **kwargs): + """Prepare a token revocation request. + + :param revocation_url: Provider token revocation endpoint URL. + :param token: The access or refresh token to be revoked (string). + :param token_type_hint: ``"access_token"`` (default) or + ``"refresh_token"``. This is optional and if you wish to not pass it you + must provide ``token_type_hint=None``. + :param body: + :param callback: A jsonp callback such as ``package.callback`` to be invoked + upon receiving the response. Not that it should not include a () suffix. + :param kwargs: Additional parameters to included in the request. + :returns: The prepared request tuple with (url, headers, body). + + Note that JSONP request may use GET requests as the parameters will + be added to the request URL query as opposed to the request body. + + An example of a revocation request + + .. code-block:: http + + POST /revoke HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW + + token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token + + An example of a jsonp revocation request + + .. code-block:: http + + GET /revoke?token=agabcdefddddafdd&callback=package.myCallback HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW + + and an error response + + .. code-block:: javascript + + package.myCallback({"error":"unsupported_token_type"}); + + Note that these requests usually require client credentials, client_id in + the case for public clients and provider specific authentication + credentials for confidential clients. + """ + if not is_secure_transport(revocation_url): + raise InsecureTransportError() + + return prepare_token_revocation_request(revocation_url, token, + token_type_hint=token_type_hint, body=body, callback=callback, + **kwargs) + + def parse_request_body_response(self, body, scope=None, **kwargs): + """Parse the JSON response body. + + If the access token request is valid and authorized, the + authorization server issues an access token as described in + `Section 5.1`_. A refresh token SHOULD NOT be included. If the request + failed client authentication or is invalid, the authorization server + returns an error response as described in `Section 5.2`_. + + :param body: The response body from the token request. + :param scope: Scopes originally requested. If none is provided, the ones + provided in the constructor are used. + :return: Dictionary of token parameters. + :raises: Warning if scope has changed. :py:class:`oauthlib.oauth2.errors.OAuth2Error` + if response is invalid. + + These response are json encoded and could easily be parsed without + the assistance of OAuthLib. However, there are a few subtle issues + to be aware of regarding the response which are helpfully addressed + through the raising of various errors. + + A successful response should always contain + + **access_token** + The access token issued by the authorization server. Often + a random string. + + **token_type** + The type of the token issued as described in `Section 7.1`_. + Commonly ``Bearer``. + + While it is not mandated it is recommended that the provider include + + **expires_in** + The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + **scope** + Providers may supply this in all responses but are required to only + if it has changed since the authorization request. + + .. _`Section 5.1`: https://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: https://tools.ietf.org/html/rfc6749#section-5.2 + .. _`Section 7.1`: https://tools.ietf.org/html/rfc6749#section-7.1 + """ + scope = self.scope if scope is None else scope + self.token = parse_token_response(body, scope=scope) + self.populate_token_attributes(self.token) + return self.token + + def prepare_refresh_body(self, body='', refresh_token=None, scope=None, **kwargs): + """Prepare an access token request, using a refresh token. + + If the authorization server issued a refresh token to the client, the + client makes a refresh request to the token endpoint by adding the + following parameters using the `application/x-www-form-urlencoded` + format in the HTTP request entity-body: + + :param refresh_token: REQUIRED. The refresh token issued to the client. + :param scope: OPTIONAL. The scope of the access request as described by + Section 3.3. The requested scope MUST NOT include any scope + not originally granted by the resource owner, and if omitted is + treated as equal to the scope originally granted by the + resource owner. Note that if none is provided, the ones provided + in the constructor are used if any. + """ + refresh_token = refresh_token or self.refresh_token + scope = self.scope if scope is None else scope + return prepare_token_request(self.refresh_token_key, body=body, scope=scope, + refresh_token=refresh_token, **kwargs) + + def _add_bearer_token(self, uri, http_method='GET', body=None, + headers=None, token_placement=None): + """Add a bearer token to the request uri, body or authorization header.""" + if token_placement == AUTH_HEADER: + headers = tokens.prepare_bearer_headers(self.access_token, headers) + + elif token_placement == URI_QUERY: + uri = tokens.prepare_bearer_uri(self.access_token, uri) + + elif token_placement == BODY: + body = tokens.prepare_bearer_body(self.access_token, body) + + else: + raise ValueError("Invalid token placement.") + return uri, headers, body + + def create_code_verifier(self, length): + """Create PKCE **code_verifier** used in computing **code_challenge**. + See `RFC7636 Section 4.1`_ + + :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-block:: text + + 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. + + .. _`RFC7636 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**. + See `RFC7636 Section 4.2`_ + + :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. + + .. _`RFC7636 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. + + Warning: MAC token support is experimental as the spec is not yet stable. + """ + if token_placement != AUTH_HEADER: + raise ValueError("Invalid token placement.") + + headers = tokens.prepare_mac_header(self.access_token, uri, + self.mac_key, http_method, headers=headers, body=body, ext=ext, + hash_algorithm=self.mac_algorithm, **kwargs) + return uri, headers, body + + def _populate_attributes(self, response): + warnings.warn("Please switch to the public method " + "populate_token_attributes.", DeprecationWarning) + return self.populate_token_attributes(response) + + def populate_code_attributes(self, response): + """Add attributes from an auth code response to self.""" + + if 'code' in response: + self.code = response.get('code') + + def populate_token_attributes(self, response): + """Add attributes from a token exchange response to self.""" + + if 'access_token' in response: + self.access_token = response.get('access_token') + + if 'refresh_token' in response: + self.refresh_token = response.get('refresh_token') + + if 'token_type' in response: + self.token_type = response.get('token_type') + + if 'expires_in' in response: + self.expires_in = response.get('expires_in') + self._expires_at = time.time() + int(self.expires_in) + + if 'expires_at' in response: + 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') + + if 'mac_algorithm' in response: + self.mac_algorithm = response.get('mac_algorithm') diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/legacy_application.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/legacy_application.py new file mode 100644 index 0000000000..9920981d2c --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/legacy_application.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from ..parameters import prepare_token_request +from .base import Client + + +class LegacyApplicationClient(Client): + + """A public client using the resource owner password and username directly. + + The resource owner password credentials grant type is suitable in + cases where the resource owner has a trust relationship with the + client, such as the device operating system or a highly privileged + application. The authorization server should take special care when + enabling this grant type, and only allow it when other flows are not + viable. + + The grant type is suitable for clients capable of obtaining the + resource owner's credentials (username and password, typically using + an interactive form). It is also used to migrate existing clients + using direct authentication schemes such as HTTP Basic or Digest + authentication to OAuth by converting the stored credentials to an + access token. + + The method through which the client obtains the resource owner + credentials is beyond the scope of this specification. The client + MUST discard the credentials once an access token has been obtained. + """ + + grant_type = 'password' + + def __init__(self, client_id, **kwargs): + super().__init__(client_id, **kwargs) + + def prepare_request_body(self, username, password, body='', scope=None, + include_client_id=False, **kwargs): + """Add the resource owner password and username to the request body. + + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format per `Appendix B`_ in the HTTP request entity-body: + + :param username: The resource owner username. + :param password: The resource owner password. + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra parameters. 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. + + If the client type is confidential or the client was issued client + credentials (or assigned other authentication requirements), the + client MUST authenticate with the authorization server as described + in `Section 3.2.1`_. + + The prepared body will include all provided credentials as well as + the ``grant_type`` parameter set to ``password``:: + + >>> from oauthlib.oauth2 import LegacyApplicationClient + >>> client = LegacyApplicationClient('your_id') + >>> client.prepare_request_body(username='foo', password='bar', scope=['hello', 'world']) + 'grant_type=password&username=foo&scope=hello+world&password=bar' + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + 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, username=username, + password=password, scope=scope, **kwargs) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/mobile_application.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/mobile_application.py new file mode 100644 index 0000000000..b10b41ced3 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/mobile_application.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from ..parameters import parse_implicit_response, prepare_grant_uri +from .base import Client + + +class MobileApplicationClient(Client): + + """A public client utilizing the implicit code grant workflow. + + A user-agent-based application is a public client in which the + client code is downloaded from a web server and executes within a + user-agent (e.g. web browser) on the device used by the resource + owner. Protocol data and credentials are easily accessible (and + often visible) to the resource owner. Since such applications + reside within the user-agent, they can make seamless use of the + user-agent capabilities when requesting authorization. + + The implicit grant type is used to obtain access tokens (it does not + support the issuance of refresh tokens) and is optimized for public + clients known to operate a particular redirection URI. These clients + are typically implemented in a browser using a scripting language + such as JavaScript. + + As a redirection-based flow, the client must be capable of + interacting with the resource owner's user-agent (typically a web + browser) and capable of receiving incoming requests (via redirection) + from the authorization server. + + Unlike the authorization code grant type in which the client makes + separate requests for authorization and access token, the client + receives the access token as the result of the authorization request. + + The implicit grant type does not include client authentication, and + relies on the presence of the resource owner and the registration of + the redirection URI. Because the access token is encoded into the + redirection URI, it may be exposed to the resource owner and other + applications residing on the same device. + """ + + response_type = 'token' + + def prepare_request_uri(self, uri, redirect_uri=None, scope=None, + state=None, **kwargs): + """Prepare the implicit grant request URI. + + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + :param redirect_uri: OPTIONAL. The redirect URI must be an absolute URI + and it should have been registered with the OAuth + provider prior to use. As described in `Section 3.1.2`_. + + :param scope: OPTIONAL. The scope of the access request as described by + Section 3.3`_. These may be any string but are commonly + URIs or various categories such as ``videos`` or ``documents``. + + :param state: RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + to the client. The parameter SHOULD be used for preventing + cross-site request forgery as described in `Section 10.12`_. + + :param kwargs: Extra arguments to include in the request URI. + + In addition to supplied parameters, OAuthLib will append the ``client_id`` + that was provided in the constructor as well as the mandatory ``response_type`` + argument, set to ``token``:: + + >>> from oauthlib.oauth2 import MobileApplicationClient + >>> client = MobileApplicationClient('your_id') + >>> client.prepare_request_uri('https://example.com') + 'https://example.com?client_id=your_id&response_type=token' + >>> client.prepare_request_uri('https://example.com', redirect_uri='https://a.b/callback') + 'https://example.com?client_id=your_id&response_type=token&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=token&scope=profile+pictures' + >>> client.prepare_request_uri('https://example.com', foo='bar') + 'https://example.com?client_id=your_id&response_type=token&foo=bar' + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 2.2`: https://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: https://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: https://tools.ietf.org/html/rfc6749#section-10.12 + """ + scope = self.scope if scope is None else scope + return prepare_grant_uri(uri, self.client_id, self.response_type, + redirect_uri=redirect_uri, state=state, scope=scope, **kwargs) + + def parse_request_uri_response(self, uri, state=None, scope=None): + """Parse the response URI fragment. + + If the resource owner grants the access request, the authorization + server issues an access token and delivers it to the client by adding + the following parameters to the fragment component of the redirection + URI using the "application/x-www-form-urlencoded" format: + + :param uri: The callback URI that resulted from the user being redirected + back from the provider to you, the client. + :param state: The state provided in the authorization request. + :param scope: The scopes provided in the authorization request. + :return: Dictionary of token parameters. + :raises: OAuth2Error if response is invalid. + + A successful response should always contain + + **access_token** + The access token issued by the authorization server. Often + a random string. + + **token_type** + The type of the token issued as described in `Section 7.1`_. + Commonly ``Bearer``. + + **state** + If you provided the state parameter in the authorization phase, then + the provider is required to include that exact state value in the + response. + + While it is not mandated it is recommended that the provider include + + **expires_in** + The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + **scope** + Providers may supply this in all responses but are required to only + if it has changed since the authorization request. + + A few example responses can be seen below:: + + >>> response_uri = 'https://example.com/callback#access_token=sdlfkj452&state=ss345asyht&token_type=Bearer&scope=hello+world' + >>> from oauthlib.oauth2 import MobileApplicationClient + >>> client = MobileApplicationClient('your_id') + >>> client.parse_request_uri_response(response_uri) + { + 'access_token': 'sdlfkj452', + 'token_type': 'Bearer', + 'state': 'ss345asyht', + 'scope': [u'hello', u'world'] + } + >>> client.parse_request_uri_response(response_uri, state='other') + Traceback (most recent call last): + File "<stdin>", line 1, in <module> + File "oauthlib/oauth2/rfc6749/__init__.py", line 598, in parse_request_uri_response + **scope** + File "oauthlib/oauth2/rfc6749/parameters.py", line 197, in parse_implicit_response + raise ValueError("Mismatching or missing state in params.") + ValueError: Mismatching or missing state in params. + >>> def alert_scope_changed(message, old, new): + ... print(message, old, new) + ... + >>> oauthlib.signals.scope_changed.connect(alert_scope_changed) + >>> client.parse_request_body_response(response_body, scope=['other']) + ('Scope has changed from "other" to "hello world".', ['other'], ['hello', 'world']) + + .. _`Section 7.1`: https://tools.ietf.org/html/rfc6749#section-7.1 + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + scope = self.scope if scope is None else scope + self.token = parse_implicit_response(uri, state=state, scope=scope) + self.populate_token_attributes(self.token) + return self.token diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/service_application.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/service_application.py new file mode 100644 index 0000000000..8fb173776d --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/service_application.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +import time + +from oauthlib.common import to_unicode + +from ..parameters import prepare_token_request +from .base import Client + + +class ServiceApplicationClient(Client): + """A public client utilizing the JWT bearer grant. + + JWT bearer tokes can be used to request an access token when a client + wishes to utilize an existing trust relationship, expressed through the + semantics of (and digital signature or keyed message digest calculated + over) the JWT, without a direct user approval step at the authorization + server. + + This grant type does not involve an authorization step. It may be + used by both public and confidential clients. + """ + + grant_type = 'urn:ietf:params:oauth:grant-type:jwt-bearer' + + def __init__(self, client_id, private_key=None, subject=None, issuer=None, + audience=None, **kwargs): + """Initialize a JWT client with defaults for implicit use later. + + :param client_id: Client identifier given by the OAuth provider upon + registration. + + :param private_key: Private key used for signing and encrypting. + Must be given as a string. + + :param subject: The principal that is the subject of the JWT, i.e. + which user is the token requested on behalf of. + For example, ``foo@example.com. + + :param issuer: The JWT MUST contain an "iss" (issuer) claim that + contains a unique identifier for the entity that issued + the JWT. For example, ``your-client@provider.com``. + + :param audience: A value identifying the authorization server as an + intended audience, e.g. + ``https://provider.com/oauth2/token``. + + :param kwargs: Additional arguments to pass to base client, such as + state and token. See ``Client.__init__.__doc__`` for + details. + """ + super().__init__(client_id, **kwargs) + self.private_key = private_key + self.subject = subject + self.issuer = issuer + self.audience = audience + + def prepare_request_body(self, + private_key=None, + subject=None, + issuer=None, + audience=None, + expires_at=None, + issued_at=None, + extra_claims=None, + body='', + scope=None, + include_client_id=False, + **kwargs): + """Create and add a JWT assertion to the request body. + + :param private_key: Private key used for signing and encrypting. + Must be given as a string. + + :param subject: (sub) The principal that is the subject of the JWT, + i.e. which user is the token requested on behalf of. + For example, ``foo@example.com. + + :param issuer: (iss) The JWT MUST contain an "iss" (issuer) claim that + contains a unique identifier for the entity that issued + the JWT. For example, ``your-client@provider.com``. + + :param audience: (aud) A value identifying the authorization server as an + intended audience, e.g. + ``https://provider.com/oauth2/token``. + + :param expires_at: A unix expiration timestamp for the JWT. Defaults + to an hour from now, i.e. ``time.time() + 3600``. + + :param issued_at: A unix timestamp of when the JWT was created. + Defaults to now, i.e. ``time.time()``. + + :param extra_claims: A dict of additional claims to include in the JWT. + + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra parameters. Default ''. + + :param scope: The scope of the access request. + + :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 not_before: A unix timestamp after which the JWT may be used. + Not included unless provided. * + + :param jwt_id: A unique JWT token identifier. Not included unless + provided. * + + :param kwargs: Extra credentials to include in the token request. + + Parameters marked with a `*` above are not explicit arguments in the + function signature, but are specially documented arguments for items + appearing in the generic `**kwargs` keyworded input. + + The "scope" parameter may be used, as defined in the Assertion + Framework for OAuth 2.0 Client Authentication and Authorization Grants + [I-D.ietf-oauth-assertions] specification, to indicate the requested + scope. + + Authentication of the client is optional, as described in + `Section 3.2.1`_ of OAuth 2.0 [RFC6749] and consequently, the + "client_id" is only needed when a form of client authentication that + relies on the parameter is used. + + The following non-normative example demonstrates an Access Token + Request with a JWT as an authorization grant (with extra line breaks + for display purposes only): + + .. code-block: http + + POST /token.oauth2 HTTP/1.1 + Host: as.example.com + Content-Type: application/x-www-form-urlencoded + + grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer + &assertion=eyJhbGciOiJFUzI1NiJ9. + eyJpc3Mi[...omitted for brevity...]. + J9l-ZhwP[...omitted for brevity...] + + .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + import jwt + + key = private_key or self.private_key + if not key: + raise ValueError('An encryption key must be supplied to make JWT' + ' token requests.') + claim = { + 'iss': issuer or self.issuer, + 'aud': audience or self.audience, + 'sub': subject or self.subject, + 'exp': int(expires_at or time.time() + 3600), + 'iat': int(issued_at or time.time()), + } + + for attr in ('iss', 'aud', 'sub'): + if claim[attr] is None: + raise ValueError( + 'Claim must include %s but none was given.' % attr) + + if 'not_before' in kwargs: + claim['nbf'] = kwargs.pop('not_before') + + if 'jwt_id' in kwargs: + claim['jti'] = kwargs.pop('jwt_id') + + claim.update(extra_claims or {}) + + assertion = jwt.encode(claim, key, 'RS256') + assertion = to_unicode(assertion) + + 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, + assertion=assertion, + scope=scope, + **kwargs) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/web_application.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/web_application.py new file mode 100644 index 0000000000..50890fbf8a --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/clients/web_application.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +import warnings + +from ..parameters import ( + parse_authorization_code_response, prepare_grant_uri, + prepare_token_request, +) +from .base import Client + + +class WebApplicationClient(Client): + + """A client utilizing the authorization code grant workflow. + + A web application is a confidential client running on a web + server. Resource owners access the client via an HTML user + interface rendered in a user-agent on the device used by the + resource owner. The client credentials as well as any access + token issued to the client are stored on the web server and are + not exposed to or accessible by the resource owner. + + The authorization code grant type is used to obtain both access + tokens and refresh tokens and is optimized for confidential clients. + As a redirection-based flow, the client must be capable of + interacting with the resource owner's user-agent (typically a web + browser) and capable of receiving incoming requests (via redirection) + from the authorization server. + """ + + grant_type = 'authorization_code' + + def __init__(self, client_id, code=None, **kwargs): + super().__init__(client_id, **kwargs) + self.code = code + + def prepare_request_uri(self, uri, redirect_uri=None, scope=None, + 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 + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + :param redirect_uri: OPTIONAL. The redirect URI must be an absolute URI + and it should have been registered with the OAuth + provider prior to use. As described in `Section 3.1.2`_. + + :param scope: OPTIONAL. The scope of the access request as described by + Section 3.3`_. These may be any string but are commonly + URIs or various categories such as ``videos`` or ``documents``. + + :param state: RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + 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`` + that was provided in the constructor as well as the mandatory ``response_type`` + argument, set to ``code``:: + + >>> from oauthlib.oauth2 import WebApplicationClient + >>> client = WebApplicationClient('your_id') + >>> client.prepare_request_uri('https://example.com') + 'https://example.com?client_id=your_id&response_type=code' + >>> client.prepare_request_uri('https://example.com', redirect_uri='https://a.b/callback') + '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' + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 2.2`: https://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: https://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: https://tools.ietf.org/html/rfc6749#section-10.12 + """ + 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, 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, code_verifier=None, **kwargs): + """Prepare the access token request body. + + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format in the HTTP request entity-body: + + :param code: REQUIRED. The authorization code received from the + authorization server. + + :param redirect_uri: REQUIRED, if the "redirect_uri" parameter was included in the + authorization request as described in `Section 4.1.1`_, and their + values MUST be identical. + + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra parameters. Default ''. + + :param include_client_id: `True` (default) 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`_. + :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 + ``authorization_code``. + + If the client type is confidential or the client was issued client + credentials (or assigned other authentication requirements), the + client MUST authenticate with the authorization server as described + in `Section 3.2.1`_:: + + >>> from oauthlib.oauth2 import WebApplicationClient + >>> 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' + + `Section 3.2.1` also states: + In the "authorization_code" "grant_type" request to the token + endpoint, an unauthenticated client MUST send its "client_id" to + prevent itself from inadvertently accepting a code intended for a + client with a different "client_id". This protects the client from + substitution of the authentication code. (It provides no additional + security for the protected resource.) + + .. _`Section 4.1.1`: https://tools.ietf.org/html/rfc6749#section-4.1.1 + .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + code = code or self.code + if 'client_id' in kwargs: + warnings.warn("`client_id` has been deprecated in favor of " + "`include_client_id`, a boolean value which will " + "include the already configured `self.client_id`.", + DeprecationWarning) + if kwargs['client_id'] != self.client_id: + raise ValueError("`client_id` was supplied as an argument, but " + "it does not match `self.client_id`") + + 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, code_verifier=code_verifier, **kwargs) + + def parse_request_uri_response(self, uri, state=None): + """Parse the URI query for code and state. + + If the resource owner grants the access request, the authorization + server issues an authorization code and delivers it to the client by + adding the following parameters to the query component of the + redirection URI using the "application/x-www-form-urlencoded" format: + + :param uri: The callback URI that resulted from the user being redirected + back from the provider to you, the client. + :param state: The state provided in the authorization request. + + **code** + The authorization code generated by the authorization server. + The authorization code MUST expire shortly after it is issued + to mitigate the risk of leaks. A maximum authorization code + lifetime of 10 minutes is RECOMMENDED. The client MUST NOT + use the authorization code more than once. If an authorization + code is used more than once, the authorization server MUST deny + the request and SHOULD revoke (when possible) all tokens + previously issued based on that authorization code. + The authorization code is bound to the client identifier and + redirection URI. + + **state** + If the "state" parameter was present in the authorization request. + + This method is mainly intended to enforce strict state checking with + the added benefit of easily extracting parameters from the URI:: + + >>> from oauthlib.oauth2 import WebApplicationClient + >>> client = WebApplicationClient('your_id') + >>> uri = 'https://example.com/callback?code=sdfkjh345&state=sfetw45' + >>> client.parse_request_uri_response(uri, state='sfetw45') + {'state': 'sfetw45', 'code': 'sdfkjh345'} + >>> client.parse_request_uri_response(uri, state='other') + Traceback (most recent call last): + File "<stdin>", line 1, in <module> + File "oauthlib/oauth2/rfc6749/__init__.py", line 357, in parse_request_uri_response + back from the provider to you, the client. + File "oauthlib/oauth2/rfc6749/parameters.py", line 153, in parse_authorization_code_response + raise MismatchingStateError() + oauthlib.oauth2.rfc6749.errors.MismatchingStateError + """ + response = parse_authorization_code_response(uri, state=state) + self.populate_code_attributes(response) + return response diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/__init__.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/__init__.py new file mode 100644 index 0000000000..1695b41b66 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/__init__.py @@ -0,0 +1,17 @@ +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from .authorization import AuthorizationEndpoint +from .introspect import IntrospectEndpoint +from .metadata import MetadataEndpoint +from .pre_configured import ( + BackendApplicationServer, LegacyApplicationServer, MobileApplicationServer, + Server, WebApplicationServer, +) +from .resource import ResourceEndpoint +from .revocation import RevocationEndpoint +from .token import TokenEndpoint diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/authorization.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/authorization.py new file mode 100644 index 0000000000..71967865dc --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/authorization.py @@ -0,0 +1,114 @@ +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +import logging + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import utils + +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class AuthorizationEndpoint(BaseEndpoint): + + """Authorization endpoint - used by the client to obtain authorization + from the resource owner via user-agent redirection. + + The authorization endpoint is used to interact with the resource + owner and obtain an authorization grant. The authorization server + MUST first verify the identity of the resource owner. The way in + which the authorization server authenticates the resource owner (e.g. + username and password login, session cookies) is beyond the scope of + this specification. + + The endpoint URI MAY include an "application/x-www-form-urlencoded" + formatted (per `Appendix B`_) query component, + which MUST be retained when adding additional query parameters. The + endpoint URI MUST NOT include a fragment component:: + + https://example.com/path?query=component # OK + https://example.com/path?query=component#fragment # Not OK + + Since requests to the authorization endpoint result in user + authentication and the transmission of clear-text credentials (in the + HTTP response), the authorization server MUST require the use of TLS + as described in Section 1.6 when sending requests to the + authorization endpoint:: + + # We will deny any request which URI schema is not with https + + The authorization server MUST support the use of the HTTP "GET" + method [RFC2616] for the authorization endpoint, and MAY support the + use of the "POST" method as well:: + + # HTTP method is currently not enforced + + Parameters sent without a value MUST be treated as if they were + omitted from the request. The authorization server MUST ignore + unrecognized request parameters. Request and response parameters + MUST NOT be included more than once:: + + # Enforced through the design of oauthlib.common.Request + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + """ + + def __init__(self, default_response_type, default_token_type, + response_types): + BaseEndpoint.__init__(self) + self._response_types = response_types + self._default_response_type = default_response_type + self._default_token_type = default_token_type + + @property + def response_types(self): + return self._response_types + + @property + def default_response_type(self): + return self._default_response_type + + @property + def default_response_type_handler(self): + return self.response_types.get(self.default_response_type) + + @property + def default_token_type(self): + return self._default_token_type + + @catch_errors_and_unavailability + def create_authorization_response(self, uri, http_method='GET', body=None, + headers=None, scopes=None, credentials=None): + """Extract response_type and route to the designated handler.""" + request = Request( + uri, http_method=http_method, body=body, headers=headers) + request.scopes = scopes + # TODO: decide whether this should be a required argument + request.user = None # TODO: explain this in docs + for k, v in (credentials or {}).items(): + setattr(request, k, v) + response_type_handler = self.response_types.get( + request.response_type, self.default_response_type_handler) + log.debug('Dispatching response_type %s request to %r.', + request.response_type, response_type_handler) + return response_type_handler.create_authorization_response( + request, self.default_token_type) + + @catch_errors_and_unavailability + def validate_authorization_request(self, uri, http_method='GET', body=None, + headers=None): + """Extract response_type and route to the designated handler.""" + request = Request( + uri, http_method=http_method, body=body, headers=headers) + + request.scopes = utils.scope_to_list(request.scope) + + response_type_handler = self.response_types.get( + request.response_type, self.default_response_type_handler) + return response_type_handler.validate_authorization_request(request) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/base.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/base.py new file mode 100644 index 0000000000..3f239917cb --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/base.py @@ -0,0 +1,113 @@ +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +import functools +import logging + +from ..errors import ( + FatalClientError, InvalidClientError, InvalidRequestError, OAuth2Error, + ServerError, TemporarilyUnavailableError, UnsupportedTokenTypeError, +) + +log = logging.getLogger(__name__) + + +class BaseEndpoint: + + def __init__(self): + self._available = True + self._catch_errors = False + self._valid_request_methods = None + + @property + def valid_request_methods(self): + return self._valid_request_methods + + @valid_request_methods.setter + def valid_request_methods(self, valid_request_methods): + if valid_request_methods is not None: + valid_request_methods = [x.upper() for x in valid_request_methods] + self._valid_request_methods = valid_request_methods + + + @property + def available(self): + return self._available + + @available.setter + def available(self, available): + self._available = available + + @property + def catch_errors(self): + return self._catch_errors + + @catch_errors.setter + def catch_errors(self, catch_errors): + self._catch_errors = catch_errors + + def _raise_on_missing_token(self, request): + """Raise error on missing token.""" + if not request.token: + raise InvalidRequestError(request=request, + description='Missing token parameter.') + def _raise_on_invalid_client(self, request): + """Raise on failed client authentication.""" + if self.request_validator.client_authentication_required(request): + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + log.debug('Client authentication failed, %r.', request) + raise InvalidClientError(request=request) + + def _raise_on_unsupported_token(self, request): + """Raise on unsupported tokens.""" + if (request.token_type_hint and + request.token_type_hint in self.valid_token_types and + request.token_type_hint not in self.supported_token_types): + raise UnsupportedTokenTypeError(request=request) + + def _raise_on_bad_method(self, request): + if self.valid_request_methods is None: + raise ValueError('Configure "valid_request_methods" property first') + if request.http_method.upper() not in self.valid_request_methods: + raise InvalidRequestError(request=request, + description=('Unsupported request method %s' % request.http_method.upper())) + + def _raise_on_bad_post_request(self, request): + """Raise if invalid POST request received + """ + if request.http_method.upper() == 'POST': + query_params = request.uri_query or "" + if query_params: + raise InvalidRequestError(request=request, + description=('URL query parameters are not allowed')) + +def catch_errors_and_unavailability(f): + @functools.wraps(f) + def wrapper(endpoint, uri, *args, **kwargs): + if not endpoint.available: + e = TemporarilyUnavailableError() + log.info('Endpoint unavailable, ignoring request %s.' % uri) + return {}, e.json, 503 + + if endpoint.catch_errors: + try: + return f(endpoint, uri, *args, **kwargs) + except OAuth2Error: + raise + except FatalClientError: + raise + except Exception as e: + error = ServerError() + log.warning( + 'Exception caught while processing request, %s.' % e) + return {}, error.json, 500 + else: + return f(endpoint, uri, *args, **kwargs) + return wrapper diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/introspect.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/introspect.py new file mode 100644 index 0000000000..3cc61e6627 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/introspect.py @@ -0,0 +1,120 @@ +""" +oauthlib.oauth2.rfc6749.endpoint.introspect +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An implementation of the OAuth 2.0 `Token Introspection`. + +.. _`Token Introspection`: https://tools.ietf.org/html/rfc7662 +""" +import json +import logging + +from oauthlib.common import Request + +from ..errors import OAuth2Error +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class IntrospectEndpoint(BaseEndpoint): + + """Introspect token endpoint. + + This endpoint defines a method to query an OAuth 2.0 authorization + server to determine the active state of an OAuth 2.0 token and to + determine meta-information about this token. OAuth 2.0 deployments + can use this method to convey information about the authorization + context of the token from the authorization server to the protected + resource. + + To prevent the values of access tokens from leaking into + server-side logs via query parameters, an authorization server + offering token introspection MAY disallow the use of HTTP GET on + the introspection endpoint and instead require the HTTP POST method + to be used at the introspection endpoint. + """ + + valid_token_types = ('access_token', 'refresh_token') + valid_request_methods = ('POST',) + + def __init__(self, request_validator, supported_token_types=None): + BaseEndpoint.__init__(self) + self.request_validator = request_validator + self.supported_token_types = ( + supported_token_types or self.valid_token_types) + + @catch_errors_and_unavailability + def create_introspect_response(self, uri, http_method='POST', body=None, + headers=None): + """Create introspect valid or invalid response + + If the authorization server is unable to determine the state + of the token without additional information, it SHOULD return + an introspection response indicating the token is not active + as described in Section 2.2. + """ + resp_headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + request = Request(uri, http_method, body, headers) + try: + self.validate_introspect_request(request) + log.debug('Token introspect valid for %r.', request) + except OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + resp_headers.update(e.headers) + return resp_headers, e.json, e.status_code + + claims = self.request_validator.introspect_token( + request.token, + request.token_type_hint, + request + ) + if claims is None: + return resp_headers, json.dumps(dict(active=False)), 200 + if "active" in claims: + claims.pop("active") + return resp_headers, json.dumps(dict(active=True, **claims)), 200 + + def validate_introspect_request(self, request): + """Ensure the request is valid. + + The protected resource calls the introspection endpoint using + an HTTP POST request with parameters sent as + "application/x-www-form-urlencoded". + + * token REQUIRED. The string value of the token. + * token_type_hint OPTIONAL. + + A hint about the type of the token submitted for + introspection. The protected resource MAY pass this parameter to + help the authorization server optimize the token lookup. If the + server is unable to locate the token using the given hint, it MUST + extend its search across all of its supported token types. An + authorization server MAY ignore this parameter, particularly if it + is able to detect the token type automatically. + + * access_token: An Access Token as defined in [`RFC6749`], `section 1.4`_ + * refresh_token: A Refresh Token as defined in [`RFC6749`], `section 1.5`_ + + The introspection endpoint MAY accept other OPTIONAL + parameters to provide further context to the query. For + instance, an authorization server may desire to know the IP + address of the client accessing the protected resource to + determine if the correct client is likely to be presenting the + token. The definition of this or any other parameters are + outside the scope of this specification, to be defined by + service documentation or extensions to this specification. + + .. _`section 1.4`: http://tools.ietf.org/html/rfc6749#section-1.4 + .. _`section 1.5`: http://tools.ietf.org/html/rfc6749#section-1.5 + .. _`RFC6749`: http://tools.ietf.org/html/rfc6749 + """ + self._raise_on_bad_method(request) + self._raise_on_bad_post_request(request) + self._raise_on_missing_token(request) + self._raise_on_invalid_client(request) + self._raise_on_unsupported_token(request) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/metadata.py new file mode 100644 index 0000000000..a2820f28a5 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -0,0 +1,238 @@ +""" +oauthlib.oauth2.rfc6749.endpoint.metadata +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An implementation of the `OAuth 2.0 Authorization Server Metadata`. + +.. _`OAuth 2.0 Authorization Server Metadata`: https://tools.ietf.org/html/rfc8414 +""" +import copy +import json +import logging + +from .. import grant_types, utils +from .authorization import AuthorizationEndpoint +from .base import BaseEndpoint, catch_errors_and_unavailability +from .introspect import IntrospectEndpoint +from .revocation import RevocationEndpoint +from .token import TokenEndpoint + +log = logging.getLogger(__name__) + + +class MetadataEndpoint(BaseEndpoint): + + """OAuth2.0 Authorization Server Metadata endpoint. + + This specification generalizes the metadata format defined by + `OpenID Connect Discovery 1.0` in a way that is compatible + with OpenID Connect Discovery while being applicable to a wider set + of OAuth 2.0 use cases. This is intentionally parallel to the way + that OAuth 2.0 Dynamic Client Registration Protocol [`RFC7591`_] + generalized the dynamic client registration mechanisms defined by + OpenID Connect Dynamic Client Registration 1.0 + in a way that is compatible with it. + + .. _`OpenID Connect Discovery 1.0`: https://openid.net/specs/openid-connect-discovery-1_0.html + .. _`RFC7591`: https://tools.ietf.org/html/rfc7591 + """ + + def __init__(self, endpoints, claims={}, raise_errors=True): + assert isinstance(claims, dict) + for endpoint in endpoints: + assert isinstance(endpoint, BaseEndpoint) + + BaseEndpoint.__init__(self) + self.raise_errors = raise_errors + self.endpoints = endpoints + self.initial_claims = claims + self.claims = self.validate_metadata_server() + + @catch_errors_and_unavailability + def create_metadata_response(self, uri, http_method='GET', body=None, + headers=None): + """Create metadata response + """ + headers = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + } + return headers, json.dumps(self.claims), 200 + + def validate_metadata(self, array, key, is_required=False, is_list=False, is_url=False, is_issuer=False): + if not self.raise_errors: + return + + if key not in array: + if is_required: + raise ValueError("key {} is a mandatory metadata.".format(key)) + + elif is_issuer: + if not utils.is_secure_transport(array[key]): + raise ValueError("key {}: {} must be an HTTPS URL".format(key, array[key])) + if "?" in array[key] or "&" in array[key] or "#" in array[key]: + raise ValueError("key {}: {} must not contain query or fragment components".format(key, array[key])) + + elif is_url: + if not array[key].startswith("http"): + raise ValueError("key {}: {} must be an URL".format(key, array[key])) + + elif is_list: + if not isinstance(array[key], list): + raise ValueError("key {}: {} must be an Array".format(key, array[key])) + for elem in array[key]: + if not isinstance(elem, str): + raise ValueError("array {}: {} must contains only string (not {})".format(key, array[key], elem)) + + def validate_metadata_token(self, claims, endpoint): + """ + If the token endpoint is used in the grant type, the value of this + parameter MUST be the same as the value of the "grant_type" + parameter passed to the token endpoint defined in the grant type + definition. + """ + self._grant_types.extend(endpoint._grant_types.keys()) + claims.setdefault("token_endpoint_auth_methods_supported", ["client_secret_post", "client_secret_basic"]) + + self.validate_metadata(claims, "token_endpoint_auth_methods_supported", is_list=True) + self.validate_metadata(claims, "token_endpoint_auth_signing_alg_values_supported", is_list=True) + self.validate_metadata(claims, "token_endpoint", is_required=True, is_url=True) + + def validate_metadata_authorization(self, claims, endpoint): + claims.setdefault("response_types_supported", + list(filter(lambda x: x != "none", endpoint._response_types.keys()))) + claims.setdefault("response_modes_supported", ["query", "fragment"]) + + # The OAuth2.0 Implicit flow is defined as a "grant type" but it is not + # using the "token" endpoint, as such, we have to add it explicitly to + # the list of "grant_types_supported" when enabled. + if "token" in claims["response_types_supported"]: + self._grant_types.append("implicit") + + self.validate_metadata(claims, "response_types_supported", is_required=True, is_list=True) + self.validate_metadata(claims, "response_modes_supported", is_list=True) + if "code" in claims["response_types_supported"]: + code_grant = endpoint._response_types["code"] + if not isinstance(code_grant, grant_types.AuthorizationCodeGrant) and hasattr(code_grant, "default_grant"): + code_grant = code_grant.default_grant + + claims.setdefault("code_challenge_methods_supported", + list(code_grant._code_challenge_methods.keys())) + self.validate_metadata(claims, "code_challenge_methods_supported", is_list=True) + self.validate_metadata(claims, "authorization_endpoint", is_required=True, is_url=True) + + def validate_metadata_revocation(self, claims, endpoint): + claims.setdefault("revocation_endpoint_auth_methods_supported", + ["client_secret_post", "client_secret_basic"]) + + self.validate_metadata(claims, "revocation_endpoint_auth_methods_supported", is_list=True) + self.validate_metadata(claims, "revocation_endpoint_auth_signing_alg_values_supported", is_list=True) + self.validate_metadata(claims, "revocation_endpoint", is_required=True, is_url=True) + + def validate_metadata_introspection(self, claims, endpoint): + claims.setdefault("introspection_endpoint_auth_methods_supported", + ["client_secret_post", "client_secret_basic"]) + + self.validate_metadata(claims, "introspection_endpoint_auth_methods_supported", is_list=True) + self.validate_metadata(claims, "introspection_endpoint_auth_signing_alg_values_supported", is_list=True) + self.validate_metadata(claims, "introspection_endpoint", is_required=True, is_url=True) + + def validate_metadata_server(self): + """ + Authorization servers can have metadata describing their + configuration. The following authorization server metadata values + are used by this specification. More details can be found in + `RFC8414 section 2`_ : + + issuer + REQUIRED + + authorization_endpoint + URL of the authorization server's authorization endpoint + [`RFC6749#Authorization`_]. This is REQUIRED unless no grant types are supported + that use the authorization endpoint. + + token_endpoint + URL of the authorization server's token endpoint [`RFC6749#Token`_]. This + is REQUIRED unless only the implicit grant type is supported. + + scopes_supported + RECOMMENDED. + + response_types_supported + REQUIRED. + + Other OPTIONAL fields: + jwks_uri, + registration_endpoint, + response_modes_supported + + grant_types_supported + OPTIONAL. JSON array containing a list of the OAuth 2.0 grant + type values that this authorization server supports. The array + values used are the same as those used with the "grant_types" + parameter defined by "OAuth 2.0 Dynamic Client Registration + Protocol" [`RFC7591`_]. If omitted, the default value is + "["authorization_code", "implicit"]". + + token_endpoint_auth_methods_supported + + token_endpoint_auth_signing_alg_values_supported + + service_documentation + + ui_locales_supported + + op_policy_uri + + op_tos_uri + + revocation_endpoint + + revocation_endpoint_auth_methods_supported + + revocation_endpoint_auth_signing_alg_values_supported + + introspection_endpoint + + introspection_endpoint_auth_methods_supported + + introspection_endpoint_auth_signing_alg_values_supported + + code_challenge_methods_supported + + Additional authorization server metadata parameters MAY also be used. + Some are defined by other specifications, such as OpenID Connect + Discovery 1.0 [`OpenID.Discovery`_]. + + .. _`RFC8414 section 2`: https://tools.ietf.org/html/rfc8414#section-2 + .. _`RFC6749#Authorization`: https://tools.ietf.org/html/rfc6749#section-3.1 + .. _`RFC6749#Token`: https://tools.ietf.org/html/rfc6749#section-3.2 + .. _`RFC7591`: https://tools.ietf.org/html/rfc7591 + .. _`OpenID.Discovery`: https://openid.net/specs/openid-connect-discovery-1_0.html + """ + claims = copy.deepcopy(self.initial_claims) + self.validate_metadata(claims, "issuer", is_required=True, is_issuer=True) + self.validate_metadata(claims, "jwks_uri", is_url=True) + self.validate_metadata(claims, "scopes_supported", is_list=True) + self.validate_metadata(claims, "service_documentation", is_url=True) + self.validate_metadata(claims, "ui_locales_supported", is_list=True) + self.validate_metadata(claims, "op_policy_uri", is_url=True) + self.validate_metadata(claims, "op_tos_uri", is_url=True) + + self._grant_types = [] + for endpoint in self.endpoints: + if isinstance(endpoint, TokenEndpoint): + self.validate_metadata_token(claims, endpoint) + if isinstance(endpoint, AuthorizationEndpoint): + self.validate_metadata_authorization(claims, endpoint) + if isinstance(endpoint, RevocationEndpoint): + self.validate_metadata_revocation(claims, endpoint) + if isinstance(endpoint, IntrospectEndpoint): + self.validate_metadata_introspection(claims, endpoint) + + # "grant_types_supported" is a combination of all OAuth2 grant types + # allowed in the current provider implementation. + claims.setdefault("grant_types_supported", self._grant_types) + self.validate_metadata(claims, "grant_types_supported", is_list=True) + return claims diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py new file mode 100644 index 0000000000..d64a166391 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py @@ -0,0 +1,216 @@ +""" +oauthlib.oauth2.rfc6749.endpoints.pre_configured +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various endpoints needed +for providing OAuth 2.0 RFC6749 servers. +""" +from ..grant_types import ( + AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, + RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, +) +from ..tokens import BearerToken +from .authorization import AuthorizationEndpoint +from .introspect import IntrospectEndpoint +from .resource import ResourceEndpoint +from .revocation import RevocationEndpoint +from .token import TokenEndpoint + + +class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, + ResourceEndpoint, RevocationEndpoint): + + """An all-in-one endpoint featuring all four major grant types.""" + + def __init__(self, request_validator, token_expires_in=None, + token_generator=None, refresh_token_generator=None, + *args, **kwargs): + """Construct a new all-grants-in-one server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + self.auth_grant = AuthorizationCodeGrant(request_validator) + self.implicit_grant = ImplicitGrant(request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant( + request_validator) + self.credentials_grant = ClientCredentialsGrant(request_validator) + self.refresh_grant = RefreshTokenGrant(request_validator) + + self.bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + + AuthorizationEndpoint.__init__(self, default_response_type='code', + response_types={ + 'code': self.auth_grant, + 'token': self.implicit_grant, + 'none': self.auth_grant + }, + default_token_type=self.bearer) + + TokenEndpoint.__init__(self, default_grant_type='authorization_code', + grant_types={ + 'authorization_code': self.auth_grant, + 'password': self.password_grant, + 'client_credentials': self.credentials_grant, + 'refresh_token': self.refresh_grant, + }, + default_token_type=self.bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': self.bearer}) + RevocationEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator) + + +class WebApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, + ResourceEndpoint, RevocationEndpoint): + + """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a new web application server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + self.auth_grant = AuthorizationCodeGrant(request_validator) + self.refresh_grant = RefreshTokenGrant(request_validator) + self.bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + AuthorizationEndpoint.__init__(self, default_response_type='code', + response_types={'code': self.auth_grant}, + default_token_type=self.bearer) + TokenEndpoint.__init__(self, default_grant_type='authorization_code', + grant_types={ + 'authorization_code': self.auth_grant, + 'refresh_token': self.refresh_grant, + }, + default_token_type=self.bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': self.bearer}) + RevocationEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator) + + +class MobileApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, + ResourceEndpoint, RevocationEndpoint): + + """An all-in-one endpoint featuring Implicit code grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a new implicit grant server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + self.implicit_grant = ImplicitGrant(request_validator) + self.bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + AuthorizationEndpoint.__init__(self, default_response_type='token', + response_types={ + 'token': self.implicit_grant}, + default_token_type=self.bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': self.bearer}) + RevocationEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) + IntrospectEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) + + +class LegacyApplicationServer(TokenEndpoint, IntrospectEndpoint, + ResourceEndpoint, RevocationEndpoint): + + """An all-in-one endpoint featuring Resource Owner Password Credentials grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a resource owner password credentials grant server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + self.password_grant = ResourceOwnerPasswordCredentialsGrant( + request_validator) + self.refresh_grant = RefreshTokenGrant(request_validator) + self.bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + TokenEndpoint.__init__(self, default_grant_type='password', + grant_types={ + 'password': self.password_grant, + 'refresh_token': self.refresh_grant, + }, + default_token_type=self.bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': self.bearer}) + RevocationEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator) + + +class BackendApplicationServer(TokenEndpoint, IntrospectEndpoint, + ResourceEndpoint, RevocationEndpoint): + + """An all-in-one endpoint featuring Client Credentials grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a client credentials grant server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + self.credentials_grant = ClientCredentialsGrant(request_validator) + self.bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + TokenEndpoint.__init__(self, default_grant_type='client_credentials', + grant_types={ + 'client_credentials': self.credentials_grant}, + default_token_type=self.bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': self.bearer}) + RevocationEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) + IntrospectEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/resource.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/resource.py new file mode 100644 index 0000000000..f7562255df --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/resource.py @@ -0,0 +1,84 @@ +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +import logging + +from oauthlib.common import Request + +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class ResourceEndpoint(BaseEndpoint): + + """Authorizes access to protected resources. + + The client accesses protected resources by presenting the access + token to the resource server. The resource server MUST validate the + access token and ensure that it has not expired and that its scope + covers the requested resource. The methods used by the resource + server to validate the access token (as well as any error responses) + are beyond the scope of this specification but generally involve an + interaction or coordination between the resource server and the + authorization server:: + + # For most cases, returning a 403 should suffice. + + The method in which the client utilizes the access token to + authenticate with the resource server depends on the type of access + token issued by the authorization server. Typically, it involves + using the HTTP "Authorization" request header field [RFC2617] with an + authentication scheme defined by the specification of the access + token type used, such as [RFC6750]:: + + # Access tokens may also be provided in query and body + https://example.com/protected?access_token=kjfch2345sdf # Query + access_token=sdf23409df # Body + """ + + def __init__(self, default_token, token_types): + BaseEndpoint.__init__(self) + self._tokens = token_types + self._default_token = default_token + + @property + def default_token(self): + return self._default_token + + @property + def default_token_type_handler(self): + return self.tokens.get(self.default_token) + + @property + def tokens(self): + return self._tokens + + @catch_errors_and_unavailability + def verify_request(self, uri, http_method='GET', body=None, headers=None, + scopes=None): + """Validate client, code etc, return body + headers""" + request = Request(uri, http_method, body, headers) + request.token_type = self.find_token_type(request) + request.scopes = scopes + token_type_handler = self.tokens.get(request.token_type, + self.default_token_type_handler) + log.debug('Dispatching token_type %s request to %r.', + request.token_type, token_type_handler) + return token_type_handler.validate_request(request), request + + def find_token_type(self, request): + """Token type identification. + + RFC 6749 does not provide a method for easily differentiating between + different token types during protected resource access. We estimate + the most likely token type (if any) by asking each known token type + to give an estimation based on the request. + """ + estimates = sorted(((t.estimate_type(request), n) + for n, t in self.tokens.items()), reverse=True) + return estimates[0][1] if len(estimates) else None diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/revocation.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/revocation.py new file mode 100644 index 0000000000..596d0860fa --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/revocation.py @@ -0,0 +1,126 @@ +""" +oauthlib.oauth2.rfc6749.endpoint.revocation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An implementation of the OAuth 2 `Token Revocation`_ spec (draft 11). + +.. _`Token Revocation`: https://tools.ietf.org/html/draft-ietf-oauth-revocation-11 +""" +import logging + +from oauthlib.common import Request + +from ..errors import OAuth2Error +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class RevocationEndpoint(BaseEndpoint): + + """Token revocation endpoint. + + Endpoint used by authenticated clients to revoke access and refresh tokens. + Commonly this will be part of the Authorization Endpoint. + """ + + valid_token_types = ('access_token', 'refresh_token') + valid_request_methods = ('POST',) + + def __init__(self, request_validator, supported_token_types=None, + enable_jsonp=False): + BaseEndpoint.__init__(self) + self.request_validator = request_validator + self.supported_token_types = ( + supported_token_types or self.valid_token_types) + self.enable_jsonp = enable_jsonp + + @catch_errors_and_unavailability + def create_revocation_response(self, uri, http_method='POST', body=None, + headers=None): + """Revoke supplied access or refresh token. + + + The authorization server responds with HTTP status code 200 if the + token has been revoked successfully or if the client submitted an + invalid token. + + Note: invalid tokens do not cause an error response since the client + cannot handle such an error in a reasonable way. Moreover, the purpose + of the revocation request, invalidating the particular token, is + already achieved. + + The content of the response body is ignored by the client as all + necessary information is conveyed in the response code. + + An invalid token type hint value is ignored by the authorization server + and does not influence the revocation response. + """ + resp_headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + request = Request( + uri, http_method=http_method, body=body, headers=headers) + try: + self.validate_revocation_request(request) + log.debug('Token revocation valid for %r.', request) + except OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + response_body = e.json + if self.enable_jsonp and request.callback: + response_body = '{}({});'.format(request.callback, response_body) + resp_headers.update(e.headers) + return resp_headers, response_body, e.status_code + + self.request_validator.revoke_token(request.token, + request.token_type_hint, request) + + response_body = '' + if self.enable_jsonp and request.callback: + response_body = request.callback + '();' + return {}, response_body, 200 + + def validate_revocation_request(self, request): + """Ensure the request is valid. + + The client constructs the request by including the following parameters + using the "application/x-www-form-urlencoded" format in the HTTP + request entity-body: + + token (REQUIRED). The token that the client wants to get revoked. + + token_type_hint (OPTIONAL). A hint about the type of the token + submitted for revocation. Clients MAY pass this parameter in order to + help the authorization server to optimize the token lookup. If the + server is unable to locate the token using the given hint, it MUST + extend its search across all of its supported token types. An + authorization server MAY ignore this parameter, particularly if it is + able to detect the token type automatically. This specification + defines two such values: + + * access_token: An Access Token as defined in [RFC6749], + `section 1.4`_ + + * refresh_token: A Refresh Token as defined in [RFC6749], + `section 1.5`_ + + Specific implementations, profiles, and extensions of this + specification MAY define other values for this parameter using + the registry defined in `Section 4.1.2`_. + + The client also includes its authentication credentials as described in + `Section 2.3`_. of [`RFC6749`_]. + + .. _`section 1.4`: https://tools.ietf.org/html/rfc6749#section-1.4 + .. _`section 1.5`: https://tools.ietf.org/html/rfc6749#section-1.5 + .. _`section 2.3`: https://tools.ietf.org/html/rfc6749#section-2.3 + .. _`Section 4.1.2`: https://tools.ietf.org/html/draft-ietf-oauth-revocation-11#section-4.1.2 + .. _`RFC6749`: https://tools.ietf.org/html/rfc6749 + """ + self._raise_on_bad_method(request) + self._raise_on_bad_post_request(request) + self._raise_on_missing_token(request) + self._raise_on_invalid_client(request) + self._raise_on_unsupported_token(request) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/token.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/token.py new file mode 100644 index 0000000000..ab9e0918b3 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/endpoints/token.py @@ -0,0 +1,119 @@ +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +import logging + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import utils + +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class TokenEndpoint(BaseEndpoint): + + """Token issuing endpoint. + + The token endpoint is used by the client to obtain an access token by + presenting its authorization grant or refresh token. The token + endpoint is used with every authorization grant except for the + implicit grant type (since an access token is issued directly). + + The means through which the client obtains the location of the token + endpoint are beyond the scope of this specification, but the location + is typically provided in the service documentation. + + The endpoint URI MAY include an "application/x-www-form-urlencoded" + formatted (per `Appendix B`_) query component, + which MUST be retained when adding additional query parameters. The + endpoint URI MUST NOT include a fragment component:: + + https://example.com/path?query=component # OK + https://example.com/path?query=component#fragment # Not OK + + Since requests to the token endpoint result in the transmission of + clear-text credentials (in the HTTP request and response), the + authorization server MUST require the use of TLS as described in + Section 1.6 when sending requests to the token endpoint:: + + # We will deny any request which URI schema is not with https + + The client MUST use the HTTP "POST" method when making access token + requests:: + + # HTTP method is currently not enforced + + Parameters sent without a value MUST be treated as if they were + omitted from the request. The authorization server MUST ignore + unrecognized request parameters. Request and response parameters + MUST NOT be included more than once:: + + # Delegated to each grant type. + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + """ + + valid_request_methods = ('POST',) + + def __init__(self, default_grant_type, default_token_type, grant_types): + BaseEndpoint.__init__(self) + self._grant_types = grant_types + self._default_token_type = default_token_type + self._default_grant_type = default_grant_type + + @property + def grant_types(self): + return self._grant_types + + @property + def default_grant_type(self): + return self._default_grant_type + + @property + def default_grant_type_handler(self): + return self.grant_types.get(self.default_grant_type) + + @property + def default_token_type(self): + return self._default_token_type + + @catch_errors_and_unavailability + def create_token_response(self, uri, http_method='POST', body=None, + headers=None, credentials=None, grant_type_for_scope=None, + claims=None): + """Extract grant_type and route to the designated handler.""" + request = Request( + uri, http_method=http_method, body=body, headers=headers) + self.validate_token_request(request) + # 'scope' is an allowed Token Request param in both the "Resource Owner Password Credentials Grant" + # and "Client Credentials Grant" flows + # https://tools.ietf.org/html/rfc6749#section-4.3.2 + # https://tools.ietf.org/html/rfc6749#section-4.4.2 + request.scopes = utils.scope_to_list(request.scope) + + request.extra_credentials = credentials + if grant_type_for_scope: + request.grant_type = grant_type_for_scope + + # OpenID Connect claims, if provided. The server using oauthlib might choose + # to implement the claims parameter of the Authorization Request. In this case + # it should retrieve those claims and pass them via the claims argument here, + # as a dict. + if claims: + request.claims = claims + + grant_type_handler = self.grant_types.get(request.grant_type, + self.default_grant_type_handler) + log.debug('Dispatching grant_type %s request to %r.', + request.grant_type, grant_type_handler) + return grant_type_handler.create_token_response( + request, self.default_token_type) + + def validate_token_request(self, request): + self._raise_on_bad_method(request) + self._raise_on_bad_post_request(request) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/errors.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/errors.py new file mode 100644 index 0000000000..da24feab75 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/errors.py @@ -0,0 +1,400 @@ +""" +oauthlib.oauth2.rfc6749.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth 2 clients and providers to represent the spec +defined error responses for all four core grant types. +""" +import json + +from oauthlib.common import add_params_to_uri, urlencode + + +class OAuth2Error(Exception): + error = None + status_code = 400 + description = '' + + def __init__(self, description=None, uri=None, state=None, + status_code=None, request=None): + """ + :param description: A human-readable ASCII [USASCII] text providing + additional information, used to assist the client + developer in understanding the error that occurred. + Values for the "error_description" parameter + MUST NOT include characters outside the set + x20-21 / x23-5B / x5D-7E. + + :param uri: A URI identifying a human-readable web page with information + about the error, used to provide the client developer with + additional information about the error. Values for the + "error_uri" parameter MUST conform to the URI- Reference + syntax, and thus MUST NOT include characters outside the set + x21 / x23-5B / x5D-7E. + + :param state: A CSRF protection value received from the client. + + :param status_code: + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + if description is not None: + self.description = description + + message = '({}) {}'.format(self.error, self.description) + if request: + message += ' ' + repr(request) + super().__init__(message) + + self.uri = uri + self.state = state + + if status_code: + self.status_code = status_code + + if request: + self.redirect_uri = request.redirect_uri + self.client_id = request.client_id + self.scopes = request.scopes + self.response_type = request.response_type + self.response_mode = request.response_mode + self.grant_type = request.grant_type + if not state: + self.state = request.state + else: + self.redirect_uri = None + self.client_id = None + self.scopes = None + self.response_type = None + self.response_mode = None + self.grant_type = None + + def in_uri(self, uri): + fragment = self.response_mode == "fragment" + return add_params_to_uri(uri, self.twotuples, fragment) + + @property + def twotuples(self): + error = [('error', self.error)] + if self.description: + error.append(('error_description', self.description)) + if self.uri: + error.append(('error_uri', self.uri)) + if self.state: + error.append(('state', self.state)) + return error + + @property + def urlencoded(self): + return urlencode(self.twotuples) + + @property + def json(self): + return json.dumps(dict(self.twotuples)) + + @property + def headers(self): + if self.status_code == 401: + """ + https://tools.ietf.org/html/rfc6750#section-3 + + All challenges defined by this specification MUST use the auth-scheme + value "Bearer". This scheme MUST be followed by one or more + auth-param values. + """ + 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": "Bearer " + ", ".join(authvalues)} + return {} + + +class TokenExpiredError(OAuth2Error): + error = 'token_expired' + + +class InsecureTransportError(OAuth2Error): + error = 'insecure_transport' + description = 'OAuth 2 MUST utilize https.' + + +class MismatchingStateError(OAuth2Error): + error = 'mismatching_state' + description = 'CSRF Warning! State not equal in request and response.' + + +class MissingCodeError(OAuth2Error): + error = 'missing_code' + + +class MissingTokenError(OAuth2Error): + error = 'missing_token' + + +class MissingTokenTypeError(OAuth2Error): + error = 'missing_token_type' + + +class FatalClientError(OAuth2Error): + """ + Errors during authorization where user should not be redirected back. + + If the request fails due to a missing, invalid, or mismatching + redirection URI, or if the client identifier is missing or invalid, + the authorization server SHOULD inform the resource owner of the + error and MUST NOT automatically redirect the user-agent to the + invalid redirection URI. + + Instead the user should be informed of the error by the provider itself. + """ + pass + + +class InvalidRequestFatalError(FatalClientError): + """ + For fatal errors, the request is missing a required parameter, includes + an invalid parameter value, includes a parameter more than once, or is + otherwise malformed. + """ + error = 'invalid_request' + + +class InvalidRedirectURIError(InvalidRequestFatalError): + description = 'Invalid redirect URI.' + + +class MissingRedirectURIError(InvalidRequestFatalError): + description = 'Missing redirect URI.' + + +class MismatchingRedirectURIError(InvalidRequestFatalError): + description = 'Mismatching redirect URI.' + + +class InvalidClientIdError(InvalidRequestFatalError): + description = 'Invalid client_id parameter value.' + + +class MissingClientIdError(InvalidRequestFatalError): + description = 'Missing client_id parameter.' + + +class InvalidRequestError(OAuth2Error): + """ + The request is missing a required parameter, includes an invalid + parameter value, includes a parameter more than once, or is + otherwise malformed. + """ + error = 'invalid_request' + + +class MissingResponseTypeError(InvalidRequestError): + description = 'Missing response_type parameter.' + + +class MissingCodeChallengeError(InvalidRequestError): + """ + If the server requires Proof Key for Code Exchange (PKCE) by OAuth + public clients and the client does not send the "code_challenge" in + the request, the authorization endpoint MUST return the authorization + error response with the "error" value set to "invalid_request". The + "error_description" or the response of "error_uri" SHOULD explain the + nature of error, e.g., code challenge required. + """ + description = 'Code challenge required.' + + +class MissingCodeVerifierError(InvalidRequestError): + """ + The request to the token endpoint, when PKCE is enabled, has + the parameter `code_verifier` REQUIRED. + """ + description = 'Code verifier required.' + + +class AccessDeniedError(OAuth2Error): + """ + The resource owner or authorization server denied the request. + """ + error = 'access_denied' + + +class UnsupportedResponseTypeError(OAuth2Error): + """ + The authorization server does not support obtaining an authorization + code using this method. + """ + error = 'unsupported_response_type' + + +class UnsupportedCodeChallengeMethodError(InvalidRequestError): + """ + If the server supporting PKCE does not support the requested + transformation, the authorization endpoint MUST return the + authorization error response with "error" value set to + "invalid_request". The "error_description" or the response of + "error_uri" SHOULD explain the nature of error, e.g., transform + algorithm not supported. + """ + description = 'Transform algorithm not supported.' + + +class InvalidScopeError(OAuth2Error): + """ + The requested scope is invalid, unknown, or malformed, or + exceeds the scope granted by the resource owner. + + https://tools.ietf.org/html/rfc6749#section-5.2 + """ + error = 'invalid_scope' + + +class ServerError(OAuth2Error): + """ + The authorization server encountered an unexpected condition that + prevented it from fulfilling the request. (This error code is needed + because a 500 Internal Server Error HTTP status code cannot be returned + to the client via a HTTP redirect.) + """ + error = 'server_error' + + +class TemporarilyUnavailableError(OAuth2Error): + """ + The authorization server is currently unable to handle the request + due to a temporary overloading or maintenance of the server. + (This error code is needed because a 503 Service Unavailable HTTP + status code cannot be returned to the client via a HTTP redirect.) + """ + error = 'temporarily_unavailable' + + +class InvalidClientError(FatalClientError): + """ + Client authentication failed (e.g. unknown client, no client + authentication included, or unsupported authentication method). + The authorization server MAY return an HTTP 401 (Unauthorized) status + code to indicate which HTTP authentication schemes are supported. + If the client attempted to authenticate via the "Authorization" request + header field, the authorization server MUST respond with an + HTTP 401 (Unauthorized) status code, and include the "WWW-Authenticate" + response header field matching the authentication scheme used by the + client. + """ + error = 'invalid_client' + status_code = 401 + + +class InvalidGrantError(OAuth2Error): + """ + The provided authorization grant (e.g. authorization code, resource + owner credentials) or refresh token is invalid, expired, revoked, does + not match the redirection URI used in the authorization request, or was + issued to another client. + + https://tools.ietf.org/html/rfc6749#section-5.2 + """ + error = 'invalid_grant' + status_code = 400 + + +class UnauthorizedClientError(OAuth2Error): + """ + The authenticated client is not authorized to use this authorization + grant type. + """ + error = 'unauthorized_client' + + +class UnsupportedGrantTypeError(OAuth2Error): + """ + The authorization grant type is not supported by the authorization + server. + """ + error = 'unsupported_grant_type' + + +class UnsupportedTokenTypeError(OAuth2Error): + """ + The authorization server does not support the hint of the + presented token type. I.e. the client tried to revoke an access token + on a server not supporting this feature. + """ + error = 'unsupported_token_type' + + +class InvalidTokenError(OAuth2Error): + """ + The access token provided is expired, revoked, malformed, or + invalid for other reasons. The resource SHOULD respond with + the HTTP 401 (Unauthorized) status code. The client MAY + request a new access token and retry the protected resource + request. + """ + error = 'invalid_token' + status_code = 401 + description = ("The access token provided is expired, revoked, malformed, " + "or invalid for other reasons.") + + +class InsufficientScopeError(OAuth2Error): + """ + The request requires higher privileges than provided by the + access token. The resource server SHOULD respond with the HTTP + 403 (Forbidden) status code and MAY include the "scope" + attribute with the scope necessary to access the protected + resource. + """ + error = 'insufficient_scope' + status_code = 403 + description = ("The request requires higher privileges than provided by " + "the access token.") + + +class ConsentRequired(OAuth2Error): + """ + The Authorization Server requires End-User consent. + + This error MAY be returned when the prompt parameter value in the + Authentication Request is none, but the Authentication Request cannot be + completed without displaying a user interface for End-User consent. + """ + error = 'consent_required' + + +class LoginRequired(OAuth2Error): + """ + The Authorization Server requires End-User authentication. + + This error MAY be returned when the prompt parameter value in the + Authentication Request is none, but the Authentication Request cannot be + completed without displaying a user interface for End-User authentication. + """ + error = 'login_required' + + +class CustomOAuth2Error(OAuth2Error): + """ + This error is a placeholder for all custom errors not described by the RFC. + Some of the popular OAuth2 providers are using custom errors. + """ + def __init__(self, error, *args, **kwargs): + self.error = error + super().__init__(*args, **kwargs) + + +def raise_from_error(error, params=None): + import inspect + import sys + kwargs = { + 'description': params.get('error_description'), + 'uri': params.get('error_uri'), + 'state': params.get('state') + } + for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + if cls.error == error: + raise cls(**kwargs) + raise CustomOAuth2Error(error=error, **kwargs) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/__init__.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/__init__.py new file mode 100644 index 0000000000..eb88cfc2e9 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/__init__.py @@ -0,0 +1,11 @@ +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from .authorization_code import AuthorizationCodeGrant +from .client_credentials import ClientCredentialsGrant +from .implicit import ImplicitGrant +from .refresh_token import RefreshTokenGrant +from .resource_owner_password_credentials import ( + ResourceOwnerPasswordCredentialsGrant, +) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py new file mode 100644 index 0000000000..858855a174 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py @@ -0,0 +1,548 @@ +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import base64 +import hashlib +import json +import logging + +from oauthlib import common + +from .. import errors +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +def code_challenge_method_s256(verifier, challenge): + """ + If the "code_challenge_method" from `Section 4.3`_ was "S256", the + received "code_verifier" is hashed by SHA-256, base64url-encoded, and + then compared to the "code_challenge", i.e.: + + BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge + + How to implement a base64url-encoding + function without padding, based upon the standard base64-encoding + function that uses padding. + + To be concrete, example C# code implementing these functions is shown + below. Similar code could be used in other languages. + + static string base64urlencode(byte [] arg) + { + string s = Convert.ToBase64String(arg); // Regular base64 encoder + s = s.Split('=')[0]; // Remove any trailing '='s + s = s.Replace('+', '-'); // 62nd char of encoding + s = s.Replace('/', '_'); // 63rd char of encoding + return s; + } + + In python urlsafe_b64encode is already replacing '+' and '/', but preserve + the trailing '='. So we have to remove it. + + .. _`Section 4.3`: https://tools.ietf.org/html/rfc7636#section-4.3 + """ + return base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).decode().rstrip('=') == challenge + + +def code_challenge_method_plain(verifier, challenge): + """ + If the "code_challenge_method" from `Section 4.3`_ was "plain", they are + compared directly, i.e.: + + code_verifier == code_challenge. + + .. _`Section 4.3`: https://tools.ietf.org/html/rfc7636#section-4.3 + """ + return verifier == challenge + + +class AuthorizationCodeGrant(GrantTypeBase): + + """`Authorization Code Grant`_ + + The authorization code grant type is used to obtain both access + tokens and refresh tokens and is optimized for confidential clients. + Since this is a redirection-based flow, the client must be capable of + interacting with the resource owner's user-agent (typically a web + browser) and capable of receiving incoming requests (via redirection) + from the authorization server:: + + +----------+ + | Resource | + | Owner | + | | + +----------+ + ^ + | + (B) + +----|-----+ Client Identifier +---------------+ + | -+----(A)-- & Redirection URI ---->| | + | User- | | Authorization | + | Agent -+----(B)-- User authenticates --->| Server | + | | | | + | -+----(C)-- Authorization Code ---<| | + +-|----|---+ +---------------+ + | | ^ v + (A) (C) | | + | | | | + ^ v | | + +---------+ | | + | |>---(D)-- Authorization Code ---------' | + | Client | & Redirection URI | + | | | + | |<---(E)----- Access Token -------------------' + +---------+ (w/ Optional Refresh Token) + + Note: The lines illustrating steps (A), (B), and (C) are broken into + two parts as they pass through the user-agent. + + Figure 3: Authorization Code Flow + + The flow illustrated in Figure 3 includes the following steps: + + (A) The client initiates the flow by directing the resource owner's + user-agent to the authorization endpoint. The client includes + its client identifier, requested scope, local state, and a + redirection URI to which the authorization server will send the + user-agent back once access is granted (or denied). + + (B) The authorization server authenticates the resource owner (via + the user-agent) and establishes whether the resource owner + grants or denies the client's access request. + + (C) Assuming the resource owner grants access, the authorization + server redirects the user-agent back to the client using the + redirection URI provided earlier (in the request or during + client registration). The redirection URI includes an + authorization code and any local state provided by the client + earlier. + + (D) The client requests an access token from the authorization + server's token endpoint by including the authorization code + received in the previous step. When making the request, the + client authenticates with the authorization server. The client + includes the redirection URI used to obtain the authorization + code for verification. + + (E) The authorization server authenticates the client, validates the + authorization code, and ensures that the redirection URI + received matches the URI used to redirect the client in + step (C). If valid, the authorization server responds back with + an access token and, optionally, a refresh token. + + OAuth 2.0 public clients utilizing the Authorization Code Grant are + susceptible to the authorization code interception attack. + + A technique to mitigate against the threat through the use of Proof Key for Code + Exchange (PKCE, pronounced "pixy") is implemented in the current oauthlib + implementation. + + .. _`Authorization Code Grant`: https://tools.ietf.org/html/rfc6749#section-4.1 + .. _`PKCE`: https://tools.ietf.org/html/rfc7636 + """ + + default_response_mode = 'query' + response_types = ['code'] + + # This dict below is private because as RFC mention it: + # "S256" is Mandatory To Implement (MTI) on the server. + # + _code_challenge_methods = { + 'plain': code_challenge_method_plain, + 'S256': code_challenge_method_s256 + } + + def create_authorization_code(self, request): + """ + Generates an authorization grant represented as a dictionary. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + grant = {'code': common.generate_token()} + if hasattr(request, 'state') and request.state: + grant['state'] = request.state + log.debug('Created authorization code grant %r for request %r.', + grant, request) + return grant + + def create_authorization_response(self, request, token_handler): + """ + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + response_type + REQUIRED. Value MUST be set to "code" for standard OAuth2 + authorization flow. For OpenID Connect it must be one of + "code token", "code id_token", or "code token id_token" - we + essentially test that "code" appears in the response_type. + client_id + REQUIRED. The client identifier as described in `Section 2.2`_. + redirect_uri + OPTIONAL. As described in `Section 3.1.2`_. + scope + OPTIONAL. The scope of the access request as described by + `Section 3.3`_. + state + RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + to the client. The parameter SHOULD be used for preventing + cross-site request forgery as described in `Section 10.12`_. + + The client directs the resource owner to the constructed URI using an + HTTP redirection response, or by other means available to it via the + user-agent. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + :returns: headers, body, status + :raises: FatalClientError on invalid redirect URI or client id. + + A few examples:: + + >>> from your_validator import your_validator + >>> request = Request('https://example.com/authorize?client_id=valid' + ... '&redirect_uri=http%3A%2F%2Fclient.com%2F') + >>> from oauthlib.common import Request + >>> from oauthlib.oauth2 import AuthorizationCodeGrant, BearerToken + >>> token = BearerToken(your_validator) + >>> grant = AuthorizationCodeGrant(your_validator) + >>> request.scopes = ['authorized', 'in', 'some', 'form'] + >>> grant.create_authorization_response(request, token) + (u'http://client.com/?error=invalid_request&error_description=Missing+response_type+parameter.', None, None, 400) + >>> request = Request('https://example.com/authorize?client_id=valid' + ... '&redirect_uri=http%3A%2F%2Fclient.com%2F' + ... '&response_type=code') + >>> request.scopes = ['authorized', 'in', 'some', 'form'] + >>> grant.create_authorization_response(request, token) + (u'http://client.com/?code=u3F05aEObJuP2k7DordviIgW5wl52N', None, None, 200) + >>> # If the client id or redirect uri fails validation + >>> grant.create_authorization_response(request, token) + Traceback (most recent call last): + File "<stdin>", line 1, in <module> + File "oauthlib/oauth2/rfc6749/grant_types.py", line 515, in create_authorization_response + >>> grant.create_authorization_response(request, token) + File "oauthlib/oauth2/rfc6749/grant_types.py", line 591, in validate_authorization_request + oauthlib.oauth2.rfc6749.errors.InvalidClientIdError + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 2.2`: https://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: https://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: https://tools.ietf.org/html/rfc6749#section-10.12 + """ + try: + self.validate_authorization_request(request) + log.debug('Pre resource owner authorization validation ok for %r.', + request) + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + except errors.FatalClientError as e: + log.debug('Fatal client error during validation of %r. %r.', + request, e) + raise + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the query component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B: + # https://tools.ietf.org/html/rfc6749#appendix-B + except errors.OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + request.redirect_uri = request.redirect_uri or self.error_uri + redirect_uri = common.add_params_to_uri( + request.redirect_uri, e.twotuples, + fragment=request.response_mode == "fragment") + return {'Location': redirect_uri}, None, 302 + + 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) + return self.prepare_authorization_response( + request, grant, {}, None, 302) + + def create_token_response(self, request, token_handler): + """Validate the authorization code. + + The client MUST NOT use the authorization code more than once. If an + authorization code is used more than once, the authorization server + MUST deny the request and SHOULD revoke (when possible) all tokens + previously issued based on that authorization code. The authorization + code is bound to the client identifier and redirection URI. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + """ + headers = self._get_default_headers() + try: + self.validate_token_request(request) + log.debug('Token request validation ok for %r.', request) + except errors.OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, refresh_token=self.refresh_token) + + for modifier in self._token_modifiers: + token = modifier(token, token_handler, request) + + 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): + """Check the authorization request for normal and fatal errors. + + A normal error could be a missing response_type parameter or the client + attempting to access scope it is not allowed to ask authorization for. + Normal errors can safely be included in the redirection URI and + sent back to the client. + + Fatal errors occur when the client_id or redirect_uri is invalid or + missing. These must be caught by the provider and handled, how this + is done is outside of the scope of OAuthLib but showing an error + page describing the issue is a good idea. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + + # First check for fatal errors + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + + # First check duplicate parameters + for param in ('client_id', 'response_type', 'redirect_uri', 'scope', 'state'): + try: + duplicate_params = request.duplicate_params + except ValueError: + raise errors.InvalidRequestFatalError(description='Unable to parse query string', request=request) + if param in duplicate_params: + raise errors.InvalidRequestFatalError(description='Duplicate %s parameter.' % param, request=request) + + # REQUIRED. The client identifier as described in Section 2.2. + # https://tools.ietf.org/html/rfc6749#section-2.2 + if not request.client_id: + raise errors.MissingClientIdError(request=request) + + if not self.request_validator.validate_client_id(request.client_id, request): + raise errors.InvalidClientIdError(request=request) + + # OPTIONAL. As described in Section 3.1.2. + # https://tools.ietf.org/html/rfc6749#section-3.1.2 + log.debug('Validating redirection uri %s for client %s.', + request.redirect_uri, request.client_id) + + # OPTIONAL. As described in Section 3.1.2. + # https://tools.ietf.org/html/rfc6749#section-3.1.2 + self._handle_redirects(request) + + # Then check for normal errors. + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the query component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B. + # https://tools.ietf.org/html/rfc6749#appendix-B + + # Note that the correct parameters to be added are automatically + # populated through the use of specific exceptions. + + request_info = {} + for validator in self.custom_validators.pre_auth: + request_info.update(validator(request)) + + # REQUIRED. + if request.response_type is None: + raise errors.MissingResponseTypeError(request=request) + # Value MUST be set to "code" or one of the OpenID authorization code including + # response_types "code token", "code id_token", "code token id_token" + elif not 'code' in request.response_type and request.response_type != 'none': + raise errors.UnsupportedResponseTypeError(request=request) + + if not self.request_validator.validate_response_type(request.client_id, + request.response_type, + request.client, request): + + log.debug('Client %s is not authorized to use response_type %s.', + request.client_id, request.response_type) + raise errors.UnauthorizedClientError(request=request) + + # OPTIONAL. Validate PKCE request or reply with "error"/"invalid_request" + # https://tools.ietf.org/html/rfc6749#section-4.4.1 + if self.request_validator.is_pkce_required(request.client_id, request) is True: + if request.code_challenge is None: + raise errors.MissingCodeChallengeError(request=request) + + if request.code_challenge is not None: + request_info["code_challenge"] = request.code_challenge + + # OPTIONAL, defaults to "plain" if not present in the request. + if request.code_challenge_method is None: + request.code_challenge_method = "plain" + + if request.code_challenge_method not in self._code_challenge_methods: + raise errors.UnsupportedCodeChallengeMethodError(request=request) + request_info["code_challenge_method"] = request.code_challenge_method + + # OPTIONAL. The scope of the access request as described by Section 3.3 + # https://tools.ietf.org/html/rfc6749#section-3.3 + self.validate_scopes(request) + + request_info.update({ + 'client_id': request.client_id, + 'redirect_uri': request.redirect_uri, + 'response_type': request.response_type, + 'state': request.state, + 'request': request + }) + + for validator in self.custom_validators.post_auth: + request_info.update(validator(request)) + + return request.scopes, request_info + + def validate_token_request(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + # REQUIRED. Value MUST be set to "authorization_code". + if request.grant_type not in ('authorization_code', 'openid'): + raise errors.UnsupportedGrantTypeError(request=request) + + for validator in self.custom_validators.pre_token: + validator(request) + + if request.code is None: + raise errors.InvalidRequestError( + description='Missing code parameter.', request=request) + + for param in ('client_id', 'grant_type', 'redirect_uri'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, + request=request) + + if self.request_validator.client_authentication_required(request): + # If the client type is confidential or the client was issued client + # credentials (or assigned other authentication requirements), the + # client MUST authenticate with the authorization server as described + # in Section 3.2.1. + # https://tools.ietf.org/html/rfc6749#section-3.2.1 + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + # REQUIRED, if the client is not authenticating with the + # authorization server as described in Section 3.2.1. + # https://tools.ietf.org/html/rfc6749#section-3.2.1 + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + + if not hasattr(request.client, 'client_id'): + raise NotImplementedError('Authenticate client must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') + + request.client_id = request.client_id or request.client.client_id + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + # REQUIRED. The authorization code received from the + # authorization server. + if not self.request_validator.validate_code(request.client_id, + request.code, request.client, request): + log.debug('Client, %r (%r), is not allowed access to scopes %r.', + request.client_id, request.client, request.scopes) + raise errors.InvalidGrantError(request=request) + + # OPTIONAL. Validate PKCE code_verifier + challenge = self.request_validator.get_code_challenge(request.code, request) + + if challenge is not None: + if request.code_verifier is None: + raise errors.MissingCodeVerifierError(request=request) + + challenge_method = self.request_validator.get_code_challenge_method(request.code, request) + if challenge_method is None: + raise errors.InvalidGrantError(request=request, description="Challenge method not found") + + if challenge_method not in self._code_challenge_methods: + raise errors.ServerError( + description="code_challenge_method {} is not supported.".format(challenge_method), + request=request + ) + + if not self.validate_code_challenge(challenge, + challenge_method, + request.code_verifier): + log.debug('request provided a invalid code_verifier.') + raise errors.InvalidGrantError(request=request) + elif self.request_validator.is_pkce_required(request.client_id, request) is True: + if request.code_verifier is None: + raise errors.MissingCodeVerifierError(request=request) + raise errors.InvalidGrantError(request=request, description="Challenge not found") + + for attr in ('user', 'scopes'): + if getattr(request, attr, None) is None: + log.debug('request.%s was not set on code validation.', attr) + + # REQUIRED, if the "redirect_uri" parameter was included in the + # authorization request as described in Section 4.1.1, and their + # values MUST be identical. + if request.redirect_uri is None: + request.using_default_redirect_uri = True + request.redirect_uri = self.request_validator.get_default_redirect_uri( + request.client_id, request) + log.debug('Using default redirect_uri %s.', request.redirect_uri) + if not request.redirect_uri: + raise errors.MissingRedirectURIError(request=request) + else: + request.using_default_redirect_uri = False + log.debug('Using provided redirect_uri %s', request.redirect_uri) + + if not self.request_validator.confirm_redirect_uri(request.client_id, request.code, + request.redirect_uri, request.client, + request): + log.debug('Redirect_uri (%r) invalid for client %r (%r).', + request.redirect_uri, request.client_id, request.client) + raise errors.MismatchingRedirectURIError(request=request) + + for validator in self.custom_validators.post_token: + validator(request) + + def validate_code_challenge(self, challenge, challenge_method, verifier): + 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) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/base.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/base.py new file mode 100644 index 0000000000..ca343a1193 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/base.py @@ -0,0 +1,268 @@ +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging +from itertools import chain + +from oauthlib.common import add_params_to_uri +from oauthlib.oauth2.rfc6749 import errors, utils +from oauthlib.uri_validate import is_absolute_uri + +from ..request_validator import RequestValidator +from ..utils import is_secure_transport + +log = logging.getLogger(__name__) + + +class ValidatorsContainer: + """ + Container object for holding custom validator callables to be invoked + as part of the grant type `validate_authorization_request()` or + `validate_authorization_request()` methods on the various grant types. + + Authorization validators must be callables that take a request object and + return a dict, which may contain items to be added to the `request_info` + returned from the grant_type after validation. + + Token validators must be callables that take a request object and + return None. + + Both authorization validators and token validators may raise OAuth2 + exceptions if validation conditions fail. + + Authorization validators added to `pre_auth` will be run BEFORE + the standard validations (but after the critical ones that raise + fatal errors) as part of `validate_authorization_request()` + + Authorization validators added to `post_auth` will be run AFTER + the standard validations as part of `validate_authorization_request()` + + Token validators added to `pre_token` will be run BEFORE + the standard validations as part of `validate_token_request()` + + Token validators added to `post_token` will be run AFTER + the standard validations as part of `validate_token_request()` + + For example: + + >>> def my_auth_validator(request): + ... return {'myval': True} + >>> auth_code_grant = AuthorizationCodeGrant(request_validator) + >>> auth_code_grant.custom_validators.pre_auth.append(my_auth_validator) + >>> def my_token_validator(request): + ... if not request.everything_okay: + ... raise errors.OAuth2Error("uh-oh") + >>> auth_code_grant.custom_validators.post_token.append(my_token_validator) + """ + + def __init__(self, post_auth, post_token, + pre_auth, pre_token): + self.pre_auth = pre_auth + self.post_auth = post_auth + self.pre_token = pre_token + self.post_token = post_token + + @property + def all_pre(self): + return chain(self.pre_auth, self.pre_token) + + @property + def all_post(self): + return chain(self.post_auth, self.post_token) + + +class GrantTypeBase: + error_uri = None + request_validator = None + default_response_mode = 'fragment' + refresh_token = True + response_types = ['code'] + + def __init__(self, request_validator=None, **kwargs): + self.request_validator = request_validator or RequestValidator() + + # Transforms class variables into instance variables: + self.response_types = self.response_types + self.refresh_token = self.refresh_token + self._setup_custom_validators(kwargs) + self._code_modifiers = [] + self._token_modifiers = [] + + for kw, val in kwargs.items(): + setattr(self, kw, val) + + def _setup_custom_validators(self, kwargs): + post_auth = kwargs.get('post_auth', []) + post_token = kwargs.get('post_token', []) + pre_auth = kwargs.get('pre_auth', []) + pre_token = kwargs.get('pre_token', []) + if not hasattr(self, 'validate_authorization_request'): + if post_auth or pre_auth: + msg = ("{} does not support authorization validators. Use " + "token validators instead.").format(self.__class__.__name__) + raise ValueError(msg) + # Using tuples here because they can't be appended to: + post_auth, pre_auth = (), () + self.custom_validators = ValidatorsContainer(post_auth, post_token, + pre_auth, pre_token) + + def register_response_type(self, response_type): + self.response_types.append(response_type) + + def register_code_modifier(self, modifier): + self._code_modifiers.append(modifier) + + def register_token_modifier(self, modifier): + self._token_modifiers.append(modifier) + + def create_authorization_response(self, request, token_handler): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + """ + raise NotImplementedError('Subclasses must implement this method.') + + def create_token_response(self, request, token_handler): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + """ + raise NotImplementedError('Subclasses must implement this method.') + + def add_token(self, token, token_handler, request): + """ + :param token: + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + # Only add a hybrid access token on auth step if asked for + if not request.response_type in ["token", "code token", "id_token token", "code id_token token"]: + return token + + token.update(token_handler.create_token(request, refresh_token=False)) + return token + + def validate_grant_type(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + client_id = getattr(request, 'client_id', None) + if not self.request_validator.validate_grant_type(client_id, + request.grant_type, request.client, request): + log.debug('Unauthorized from %r (%r) access to grant type %s.', + request.client_id, request.client, request.grant_type) + raise errors.UnauthorizedClientError(request=request) + + def validate_scopes(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + if not request.scopes: + request.scopes = utils.scope_to_list(request.scope) or utils.scope_to_list( + self.request_validator.get_default_scopes(request.client_id, request)) + log.debug('Validating access to scopes %r for client %r (%r).', + request.scopes, request.client_id, request.client) + if not self.request_validator.validate_scopes(request.client_id, + request.scopes, request.client, request): + raise errors.InvalidScopeError(request=request) + + def prepare_authorization_response(self, request, token, headers, body, status): + """Place token according to response mode. + + Base classes can define a default response mode for their authorization + response by overriding the static `default_response_mode` member. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token: + :param headers: + :param body: + :param status: + """ + request.response_mode = request.response_mode or self.default_response_mode + + if request.response_mode not in ('query', 'fragment'): + log.debug('Overriding invalid response mode %s with %s', + request.response_mode, self.default_response_mode) + request.response_mode = self.default_response_mode + + token_items = token.items() + + if request.response_type == 'none': + state = token.get('state', None) + if state: + token_items = [('state', state)] + else: + token_items = [] + + if request.response_mode == 'query': + headers['Location'] = add_params_to_uri( + request.redirect_uri, token_items, fragment=False) + return headers, body, status + + if request.response_mode == 'fragment': + headers['Location'] = add_params_to_uri( + request.redirect_uri, token_items, fragment=True) + return headers, body, status + + raise NotImplementedError( + 'Subclasses must set a valid default_response_mode') + + def _get_default_headers(self): + """Create default headers for grant responses.""" + return { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + + def _handle_redirects(self, request): + if request.redirect_uri is not None: + request.using_default_redirect_uri = False + log.debug('Using provided redirect_uri %s', request.redirect_uri) + if not is_absolute_uri(request.redirect_uri): + raise errors.InvalidRedirectURIError(request=request) + + # The authorization server MUST verify that the redirection URI + # to which it will redirect the access token matches a + # redirection URI registered by the client as described in + # Section 3.1.2. + # https://tools.ietf.org/html/rfc6749#section-3.1.2 + if not self.request_validator.validate_redirect_uri( + request.client_id, request.redirect_uri, request): + raise errors.MismatchingRedirectURIError(request=request) + else: + request.redirect_uri = self.request_validator.get_default_redirect_uri( + request.client_id, request) + request.using_default_redirect_uri = True + log.debug('Using default redirect_uri %s.', request.redirect_uri) + if not request.redirect_uri: + raise errors.MissingRedirectURIError(request=request) + if not is_absolute_uri(request.redirect_uri): + raise errors.InvalidRedirectURIError(request=request) + + 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/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py new file mode 100644 index 0000000000..e7b4618977 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py @@ -0,0 +1,123 @@ +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import json +import logging + +from .. import errors +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class ClientCredentialsGrant(GrantTypeBase): + + """`Client Credentials Grant`_ + + The client can request an access token using only its client + credentials (or other supported means of authentication) when the + client is requesting access to the protected resources under its + control, or those of another resource owner that have been previously + arranged with the authorization server (the method of which is beyond + the scope of this specification). + + The client credentials grant type MUST only be used by confidential + clients:: + + +---------+ +---------------+ + : : : : + : :>-- A - Client Authentication --->: Authorization : + : Client : : Server : + : :<-- B ---- Access Token ---------<: : + : : : : + +---------+ +---------------+ + + Figure 6: Client Credentials Flow + + The flow illustrated in Figure 6 includes the following steps: + + (A) The client authenticates with the authorization server and + requests an access token from the token endpoint. + + (B) The authorization server authenticates the client, and if valid, + issues an access token. + + .. _`Client Credentials Grant`: https://tools.ietf.org/html/rfc6749#section-4.4 + """ + + def create_token_response(self, request, token_handler): + """Return token or error in JSON format. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + If the access token request is valid and authorized, the + authorization server issues an access token as described in + `Section 5.1`_. A refresh token SHOULD NOT be included. If the request + failed client authentication or is invalid, the authorization server + returns an error response as described in `Section 5.2`_. + + .. _`Section 5.1`: https://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: https://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = self._get_default_headers() + try: + log.debug('Validating access token request, %r.', request) + self.validate_token_request(request) + except errors.OAuth2Error as e: + log.debug('Client error in token request. %s.', e) + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, refresh_token=False) + + for modifier in self._token_modifiers: + token = modifier(token) + + self.request_validator.save_token(token, request) + + log.debug('Issuing token to client id %r (%r), %r.', + request.client_id, request.client, token) + return headers, json.dumps(token), 200 + + def validate_token_request(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + for validator in self.custom_validators.pre_token: + validator(request) + + if not getattr(request, 'grant_type', None): + raise errors.InvalidRequestError('Request is missing grant type.', + request=request) + + if not request.grant_type == 'client_credentials': + raise errors.UnsupportedGrantTypeError(request=request) + + for param in ('grant_type', 'scope'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, + request=request) + + log.debug('Authenticating client, %r.', request) + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + else: + if not hasattr(request.client, 'client_id'): + raise NotImplementedError('Authenticate client must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + request.client_id = request.client_id or request.client.client_id + log.debug('Authorizing access to client %r.', request.client_id) + self.validate_scopes(request) + + for validator in self.custom_validators.post_token: + validator(request) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/implicit.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/implicit.py new file mode 100644 index 0000000000..6110b6f337 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/implicit.py @@ -0,0 +1,376 @@ +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +from oauthlib import common + +from .. import errors +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class ImplicitGrant(GrantTypeBase): + + """`Implicit Grant`_ + + The implicit grant type is used to obtain access tokens (it does not + support the issuance of refresh tokens) and is optimized for public + clients known to operate a particular redirection URI. These clients + are typically implemented in a browser using a scripting language + such as JavaScript. + + Unlike the authorization code grant type, in which the client makes + separate requests for authorization and for an access token, the + client receives the access token as the result of the authorization + request. + + The implicit grant type does not include client authentication, and + relies on the presence of the resource owner and the registration of + the redirection URI. Because the access token is encoded into the + redirection URI, it may be exposed to the resource owner and other + applications residing on the same device:: + + +----------+ + | Resource | + | Owner | + | | + +----------+ + ^ + | + (B) + +----|-----+ Client Identifier +---------------+ + | -+----(A)-- & Redirection URI --->| | + | User- | | Authorization | + | Agent -|----(B)-- User authenticates -->| Server | + | | | | + | |<---(C)--- Redirection URI ----<| | + | | with Access Token +---------------+ + | | in Fragment + | | +---------------+ + | |----(D)--- Redirection URI ---->| Web-Hosted | + | | without Fragment | Client | + | | | Resource | + | (F) |<---(E)------- Script ---------<| | + | | +---------------+ + +-|--------+ + | | + (A) (G) Access Token + | | + ^ v + +---------+ + | | + | Client | + | | + +---------+ + + Note: The lines illustrating steps (A) and (B) are broken into two + parts as they pass through the user-agent. + + Figure 4: Implicit Grant Flow + + The flow illustrated in Figure 4 includes the following steps: + + (A) The client initiates the flow by directing the resource owner's + user-agent to the authorization endpoint. The client includes + its client identifier, requested scope, local state, and a + redirection URI to which the authorization server will send the + user-agent back once access is granted (or denied). + + (B) The authorization server authenticates the resource owner (via + the user-agent) and establishes whether the resource owner + grants or denies the client's access request. + + (C) Assuming the resource owner grants access, the authorization + server redirects the user-agent back to the client using the + redirection URI provided earlier. The redirection URI includes + the access token in the URI fragment. + + (D) The user-agent follows the redirection instructions by making a + request to the web-hosted client resource (which does not + include the fragment per [RFC2616]). The user-agent retains the + fragment information locally. + + (E) The web-hosted client resource returns a web page (typically an + HTML document with an embedded script) capable of accessing the + full redirection URI including the fragment retained by the + user-agent, and extracting the access token (and other + parameters) contained in the fragment. + + (F) The user-agent executes the script provided by the web-hosted + client resource locally, which extracts the access token. + + (G) The user-agent passes the access token to the client. + + See `Section 10.3`_ and `Section 10.16`_ for important security considerations + when using the implicit grant. + + .. _`Implicit Grant`: https://tools.ietf.org/html/rfc6749#section-4.2 + .. _`Section 10.3`: https://tools.ietf.org/html/rfc6749#section-10.3 + .. _`Section 10.16`: https://tools.ietf.org/html/rfc6749#section-10.16 + """ + + response_types = ['token'] + grant_allows_refresh_token = False + + def create_authorization_response(self, request, token_handler): + """Create an authorization response. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + response_type + REQUIRED. Value MUST be set to "token" for standard OAuth2 implicit flow + or "id_token token" or just "id_token" for OIDC implicit flow + + client_id + REQUIRED. The client identifier as described in `Section 2.2`_. + + redirect_uri + OPTIONAL. As described in `Section 3.1.2`_. + + scope + OPTIONAL. The scope of the access request as described by + `Section 3.3`_. + + state + RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + to the client. The parameter SHOULD be used for preventing + cross-site request forgery as described in `Section 10.12`_. + + The authorization server validates the request to ensure that all + required parameters are present and valid. The authorization server + MUST verify that the redirection URI to which it will redirect the + access token matches a redirection URI registered by the client as + described in `Section 3.1.2`_. + + .. _`Section 2.2`: https://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: https://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: https://tools.ietf.org/html/rfc6749#section-10.12 + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + """ + return self.create_token_response(request, token_handler) + + def create_token_response(self, request, token_handler): + """Return token or error embedded in the URI fragment. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + If the resource owner grants the access request, the authorization + server issues an access token and delivers it to the client by adding + the following parameters to the fragment component of the redirection + URI using the "application/x-www-form-urlencoded" format, per + `Appendix B`_: + + access_token + REQUIRED. The access token issued by the authorization server. + + token_type + REQUIRED. The type of the token issued as described in + `Section 7.1`_. Value is case insensitive. + + expires_in + RECOMMENDED. The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + scope + OPTIONAL, if identical to the scope requested by the client; + otherwise, REQUIRED. The scope of the access token as + described by `Section 3.3`_. + + state + REQUIRED if the "state" parameter was present in the client + authorization request. The exact value received from the + client. + + The authorization server MUST NOT issue a refresh token. + + .. _`Appendix B`: https://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 7.1`: https://tools.ietf.org/html/rfc6749#section-7.1 + """ + try: + self.validate_token_request(request) + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + except errors.FatalClientError as e: + log.debug('Fatal client error during validation of %r. %r.', + request, e) + raise + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the fragment component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B: + # https://tools.ietf.org/html/rfc6749#appendix-B + except errors.OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + return {'Location': common.add_params_to_uri(request.redirect_uri, e.twotuples, + fragment=True)}, None, 302 + + # In OIDC implicit flow it is possible to have a request_type that does not include the access_token! + # "id_token token" - return the access token and the id token + # "id_token" - don't return the access token + if "token" in request.response_type.split(): + token = token_handler.create_token(request, refresh_token=False) + else: + token = {} + + if request.state is not None: + token['state'] = request.state + + for modifier in self._token_modifiers: + token = modifier(token, token_handler, request) + + # In OIDC implicit flow it is possible to have a request_type that does + # not include the access_token! In this case there is no need to save a token. + if "token" in request.response_type.split(): + self.request_validator.save_token(token, request) + + return self.prepare_authorization_response( + request, token, {}, None, 302) + + def validate_authorization_request(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + return self.validate_token_request(request) + + def validate_token_request(self, request): + """Check the token request for normal and fatal errors. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + This method is very similar to validate_authorization_request in + the AuthorizationCodeGrant but differ in a few subtle areas. + + A normal error could be a missing response_type parameter or the client + attempting to access scope it is not allowed to ask authorization for. + Normal errors can safely be included in the redirection URI and + sent back to the client. + + Fatal errors occur when the client_id or redirect_uri is invalid or + missing. These must be caught by the provider and handled, how this + is done is outside of the scope of OAuthLib but showing an error + page describing the issue is a good idea. + """ + + # First check for fatal errors + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + + # First check duplicate parameters + for param in ('client_id', 'response_type', 'redirect_uri', 'scope', 'state'): + try: + duplicate_params = request.duplicate_params + except ValueError: + raise errors.InvalidRequestFatalError(description='Unable to parse query string', request=request) + if param in duplicate_params: + raise errors.InvalidRequestFatalError(description='Duplicate %s parameter.' % param, request=request) + + # REQUIRED. The client identifier as described in Section 2.2. + # https://tools.ietf.org/html/rfc6749#section-2.2 + if not request.client_id: + raise errors.MissingClientIdError(request=request) + + if not self.request_validator.validate_client_id(request.client_id, request): + raise errors.InvalidClientIdError(request=request) + + # OPTIONAL. As described in Section 3.1.2. + # https://tools.ietf.org/html/rfc6749#section-3.1.2 + self._handle_redirects(request) + + # Then check for normal errors. + + request_info = self._run_custom_validators(request, + self.custom_validators.all_pre) + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the fragment component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B. + # https://tools.ietf.org/html/rfc6749#appendix-B + + # Note that the correct parameters to be added are automatically + # populated through the use of specific exceptions + + # REQUIRED. + if request.response_type is None: + raise errors.MissingResponseTypeError(request=request) + # Value MUST be one of our registered types: "token" by default or if using OIDC "id_token" or "id_token token" + elif not set(request.response_type.split()).issubset(self.response_types): + raise errors.UnsupportedResponseTypeError(request=request) + + log.debug('Validating use of response_type token for client %r (%r).', + request.client_id, request.client) + if not self.request_validator.validate_response_type(request.client_id, + request.response_type, + request.client, request): + + log.debug('Client %s is not authorized to use response_type %s.', + request.client_id, request.response_type) + raise errors.UnauthorizedClientError(request=request) + + # OPTIONAL. The scope of the access request as described by Section 3.3 + # https://tools.ietf.org/html/rfc6749#section-3.3 + self.validate_scopes(request) + + request_info.update({ + 'client_id': request.client_id, + 'redirect_uri': request.redirect_uri, + 'response_type': request.response_type, + 'state': request.state, + 'request': request, + }) + + request_info = self._run_custom_validators( + request, + self.custom_validators.all_post, + request_info + ) + + return request.scopes, request_info + + def _run_custom_validators(self, + request, + validations, + request_info=None): + # Make a copy so we don't modify the existing request_info dict + request_info = {} if request_info is None else request_info.copy() + # For implicit grant, auth_validators and token_validators are + # basically equivalent since the token is returned from the + # authorization endpoint. + for validator in validations: + result = validator(request) + if result is not None: + request_info.update(result) + return request_info diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py new file mode 100644 index 0000000000..ce33df0e7d --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py @@ -0,0 +1,136 @@ +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import json +import logging + +from .. import errors, utils +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class RefreshTokenGrant(GrantTypeBase): + + """`Refresh token grant`_ + + .. _`Refresh token grant`: https://tools.ietf.org/html/rfc6749#section-6 + """ + + def __init__(self, request_validator=None, + issue_new_refresh_tokens=True, + **kwargs): + super().__init__( + request_validator, + issue_new_refresh_tokens=issue_new_refresh_tokens, + **kwargs) + + def create_token_response(self, request, token_handler): + """Create a new access token from a refresh_token. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + If valid and authorized, the authorization server issues an access + token as described in `Section 5.1`_. If the request failed + verification or is invalid, the authorization server returns an error + response as described in `Section 5.2`_. + + The authorization server MAY issue a new refresh token, in which case + the client MUST discard the old refresh token and replace it with the + new refresh token. The authorization server MAY revoke the old + refresh token after issuing a new refresh token to the client. If a + new refresh token is issued, the refresh token scope MUST be + identical to that of the refresh token included by the client in the + request. + + .. _`Section 5.1`: https://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: https://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = self._get_default_headers() + try: + log.debug('Validating refresh token request, %r.', request) + self.validate_token_request(request) + except errors.OAuth2Error as e: + log.debug('Client error in token request, %s.', e) + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, + refresh_token=self.issue_new_refresh_tokens) + + for modifier in self._token_modifiers: + token = modifier(token, token_handler, request) + + self.request_validator.save_token(token, request) + + log.debug('Issuing new token to client id %r (%r), %r.', + request.client_id, request.client, token) + headers.update(self._create_cors_headers(request)) + return headers, json.dumps(token), 200 + + def validate_token_request(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + # REQUIRED. Value MUST be set to "refresh_token". + if request.grant_type != 'refresh_token': + raise errors.UnsupportedGrantTypeError(request=request) + + for validator in self.custom_validators.pre_token: + validator(request) + + if request.refresh_token is None: + raise errors.InvalidRequestError( + description='Missing refresh token parameter.', + request=request) + + # Because refresh tokens are typically long-lasting credentials used to + # request additional access tokens, the refresh token is bound to the + # client to which it was issued. If the client type is confidential or + # the client was issued client credentials (or assigned other + # authentication requirements), the client MUST authenticate with the + # authorization server as described in Section 3.2.1. + # https://tools.ietf.org/html/rfc6749#section-3.2.1 + if self.request_validator.client_authentication_required(request): + log.debug('Authenticating client, %r.', request) + if not self.request_validator.authenticate_client(request): + log.debug('Invalid client (%r), denying access.', request) + raise errors.InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + # REQUIRED. The refresh token issued to the client. + log.debug('Validating refresh token %s for client %r.', + request.refresh_token, request.client) + if not self.request_validator.validate_refresh_token( + request.refresh_token, request.client, request): + log.debug('Invalid refresh token, %s, for client %r.', + request.refresh_token, request.client) + raise errors.InvalidGrantError(request=request) + + original_scopes = utils.scope_to_list( + self.request_validator.get_original_scopes( + request.refresh_token, request)) + + if request.scope: + request.scopes = utils.scope_to_list(request.scope) + if (not all(s in original_scopes for s in request.scopes) + and not self.request_validator.is_within_original_scope( + request.scopes, request.refresh_token, request)): + log.debug('Refresh token %s lack requested scopes, %r.', + request.refresh_token, request.scopes) + raise errors.InvalidScopeError(request=request) + else: + request.scopes = original_scopes + + for validator in self.custom_validators.post_token: + validator(request) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py new file mode 100644 index 0000000000..4b0de5bf6f --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py @@ -0,0 +1,199 @@ +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import json +import logging + +from .. import errors +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class ResourceOwnerPasswordCredentialsGrant(GrantTypeBase): + + """`Resource Owner Password Credentials Grant`_ + + The resource owner password credentials grant type is suitable in + cases where the resource owner has a trust relationship with the + client, such as the device operating system or a highly privileged + application. The authorization server should take special care when + enabling this grant type and only allow it when other flows are not + viable. + + This grant type is suitable for clients capable of obtaining the + resource owner's credentials (username and password, typically using + an interactive form). It is also used to migrate existing clients + using direct authentication schemes such as HTTP Basic or Digest + authentication to OAuth by converting the stored credentials to an + access token:: + + +----------+ + | Resource | + | Owner | + | | + +----------+ + v + | Resource Owner + (A) Password Credentials + | + v + +---------+ +---------------+ + | |>--(B)---- Resource Owner ------->| | + | | Password Credentials | Authorization | + | Client | | Server | + | |<--(C)---- Access Token ---------<| | + | | (w/ Optional Refresh Token) | | + +---------+ +---------------+ + + Figure 5: Resource Owner Password Credentials Flow + + The flow illustrated in Figure 5 includes the following steps: + + (A) The resource owner provides the client with its username and + password. + + (B) The client requests an access token from the authorization + server's token endpoint by including the credentials received + from the resource owner. When making the request, the client + authenticates with the authorization server. + + (C) The authorization server authenticates the client and validates + the resource owner credentials, and if valid, issues an access + token. + + .. _`Resource Owner Password Credentials Grant`: https://tools.ietf.org/html/rfc6749#section-4.3 + """ + + def create_token_response(self, request, token_handler): + """Return token or error in json format. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + If the access token request is valid and authorized, the + authorization server issues an access token and optional refresh + token as described in `Section 5.1`_. If the request failed client + authentication or is invalid, the authorization server returns an + error response as described in `Section 5.2`_. + + .. _`Section 5.1`: https://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: https://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = self._get_default_headers() + try: + if self.request_validator.client_authentication_required(request): + log.debug('Authenticating client, %r.', request) + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + log.debug('Validating access token request, %r.', request) + self.validate_token_request(request) + except errors.OAuth2Error as e: + log.debug('Client error in token request, %s.', e) + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, self.refresh_token) + + for modifier in self._token_modifiers: + token = modifier(token) + + self.request_validator.save_token(token, request) + + log.debug('Issuing token %r to client id %r (%r) and username %s.', + token, request.client_id, request.client, request.username) + return headers, json.dumps(token), 200 + + def validate_token_request(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format per Appendix B with a character encoding of UTF-8 in the HTTP + request entity-body: + + grant_type + REQUIRED. Value MUST be set to "password". + + username + REQUIRED. The resource owner username. + + password + REQUIRED. The resource owner password. + + scope + OPTIONAL. The scope of the access request as described by + `Section 3.3`_. + + If the client type is confidential or the client was issued client + credentials (or assigned other authentication requirements), the + client MUST authenticate with the authorization server as described + in `Section 3.2.1`_. + + The authorization server MUST: + + o require client authentication for confidential clients or for any + client that was issued client credentials (or with other + authentication requirements), + + o authenticate the client if client authentication is included, and + + o validate the resource owner password credentials using its + existing password validation algorithm. + + Since this access token request utilizes the resource owner's + password, the authorization server MUST protect the endpoint against + brute force attacks (e.g., using rate-limitation or generating + alerts). + + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + for validator in self.custom_validators.pre_token: + validator(request) + + for param in ('grant_type', 'username', 'password'): + if not getattr(request, param, None): + raise errors.InvalidRequestError( + 'Request is missing %s parameter.' % param, request=request) + + for param in ('grant_type', 'username', 'password', 'scope'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, request=request) + + # This error should rarely (if ever) occur if requests are routed to + # grant type handlers based on the grant_type parameter. + if not request.grant_type == 'password': + raise errors.UnsupportedGrantTypeError(request=request) + + log.debug('Validating username %s.', request.username) + if not self.request_validator.validate_user(request.username, + request.password, request.client, request): + raise errors.InvalidGrantError( + 'Invalid credentials given.', request=request) + else: + if not hasattr(request.client, 'client_id'): + raise NotImplementedError( + 'Validate user must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') + log.debug('Authorizing access to user %r.', request.user) + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + if request.client: + request.client_id = request.client_id or request.client.client_id + self.validate_scopes(request) + + for validator in self.custom_validators.post_token: + validator(request) diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/parameters.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/parameters.py new file mode 100644 index 0000000000..8f6ce2c7fc --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/parameters.py @@ -0,0 +1,471 @@ +""" +oauthlib.oauth2.rfc6749.parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains methods related to `Section 4`_ of the OAuth 2 RFC. + +.. _`Section 4`: https://tools.ietf.org/html/rfc6749#section-4 +""" +import json +import os +import time +import urllib.parse as urlparse + +from oauthlib.common import add_params_to_qs, add_params_to_uri +from oauthlib.signals import scope_changed + +from .errors import ( + InsecureTransportError, MismatchingStateError, MissingCodeError, + MissingTokenError, MissingTokenTypeError, raise_from_error, +) +from .tokens import OAuth2Token +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, code_challenge=None, code_challenge_method='plain', **kwargs): + """Prepare the authorization grant request URI. + + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the ``application/x-www-form-urlencoded`` format as defined by + [`W3C.REC-html401-19991224`_]: + + :param uri: + :param client_id: The client identifier as described in `Section 2.2`_. + :param response_type: To indicate which OAuth 2 grant/flow is required, + "code" and "token". + :param redirect_uri: The client provided URI to redirect back to after + authorization as described in `Section 3.1.2`_. + :param scope: The scope of the access request as described by + `Section 3.3`_. + :param state: An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent + 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 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 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: + + .. 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 + + .. _`W3C.REC-html401-19991224`: https://tools.ietf.org/html/rfc6749#ref-W3C.REC-html401-19991224 + .. _`Section 2.2`: https://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: https://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`section 10.12`: https://tools.ietf.org/html/rfc6749#section-10.12 + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + params = [(('response_type', response_type)), + (('client_id', client_id))] + + if redirect_uri: + params.append(('redirect_uri', redirect_uri)) + if scope: + 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]: + params.append((str(k), kwargs[k])) + + return add_params_to_uri(uri, params) + + +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 + following parameters using the ``application/x-www-form-urlencoded`` + format in the HTTP request entity-body: + + :param grant_type: To indicate grant type being used, i.e. "password", + "authorization_code" or "client_credentials". + + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra parameters. Default ''. + + :param include_client_id: `True` (default) 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`_. + :type include_client_id: Boolean + + :param client_id: Unicode client identifier. Will only appear if + `include_client_id` is True. * + + :param client_secret: Unicode client secret. Will only appear if set to a + value that is not `None`. Invoking this function with + an empty string will send an empty `client_secret` + value to the server. * + + :param code: If using authorization_code grant, pass the previously + obtained authorization code as the ``code`` argument. * + + :param redirect_uri: If the "redirect_uri" parameter was included in the + 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 + function signature, but are specially documented arguments for items + appearing in the generic `**kwargs` keyworded input. + + An example of an authorization code token request body: + + .. code-block:: http + + grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA + &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb + + .. _`Section 4.1.1`: https://tools.ietf.org/html/rfc6749#section-4.1.1 + """ + params = [('grant_type', grant_type)] + + if 'scope' in kwargs: + kwargs['scope'] = list_to_scope(kwargs['scope']) + + # pull the `client_id` out of the kwargs. + client_id = kwargs.pop('client_id', None) + if include_client_id: + 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) + if client_secret is not None: + params.append(('client_secret', client_secret)) + + # this handles: `code`, `redirect_uri`, and other undocumented params + for k in kwargs: + if kwargs[k]: + params.append((str(k), kwargs[k])) + + return add_params_to_qs(body, params) + + +def prepare_token_revocation_request(url, token, token_type_hint="access_token", + callback=None, body='', **kwargs): + """Prepare a token revocation request. + + The client constructs the request by including the following parameters + using the ``application/x-www-form-urlencoded`` format in the HTTP request + entity-body: + + :param token: REQUIRED. The token that the client wants to get revoked. + + :param token_type_hint: OPTIONAL. A hint about the type of the token + submitted for revocation. Clients MAY pass this + parameter in order to help the authorization server + to optimize the token lookup. If the server is + unable to locate the token using the given hint, it + MUST extend its search across all of its supported + token types. An authorization server MAY ignore + this parameter, particularly if it is able to detect + the token type automatically. + + This specification defines two values for `token_type_hint`: + + * access_token: An access token as defined in [RFC6749], + `Section 1.4`_ + + * refresh_token: A refresh token as defined in [RFC6749], + `Section 1.5`_ + + Specific implementations, profiles, and extensions of this + specification MAY define other values for this parameter using the + registry defined in `Section 4.1.2`_. + + .. _`Section 1.4`: https://tools.ietf.org/html/rfc6749#section-1.4 + .. _`Section 1.5`: https://tools.ietf.org/html/rfc6749#section-1.5 + .. _`Section 4.1.2`: https://tools.ietf.org/html/rfc7009#section-4.1.2 + + """ + if not is_secure_transport(url): + raise InsecureTransportError() + + params = [('token', token)] + + if token_type_hint: + params.append(('token_type_hint', token_type_hint)) + + for k in kwargs: + if kwargs[k]: + params.append((str(k), kwargs[k])) + + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + if callback: + params.append(('callback', callback)) + return add_params_to_uri(url, params), headers, body + else: + return url, headers, add_params_to_qs(body, params) + + +def parse_authorization_code_response(uri, state=None): + """Parse authorization grant response URI into a dict. + + If the resource owner grants the access request, the authorization + server issues an authorization code and delivers it to the client by + adding the following parameters to the query component of the + redirection URI using the ``application/x-www-form-urlencoded`` format: + + **code** + REQUIRED. The authorization code generated by the + authorization server. The authorization code MUST expire + shortly after it is issued to mitigate the risk of leaks. A + maximum authorization code lifetime of 10 minutes is + RECOMMENDED. The client MUST NOT use the authorization code + more than once. If an authorization code is used more than + once, the authorization server MUST deny the request and SHOULD + revoke (when possible) all tokens previously issued based on + that authorization code. The authorization code is bound to + the client identifier and redirection URI. + + **state** + REQUIRED if the "state" parameter was present in the client + authorization request. The exact value received from the + client. + + :param uri: The full redirect URL back to the client. + :param state: The state parameter from the authorization request. + + For example, the authorization server redirects the user-agent by + sending the following HTTP response: + + .. code-block:: http + + HTTP/1.1 302 Found + Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA + &state=xyz + + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + query = urlparse.urlparse(uri).query + params = dict(urlparse.parse_qsl(query)) + + if state and params.get('state', None) != state: + raise MismatchingStateError() + + if 'error' in params: + raise_from_error(params.get('error'), params) + + if not 'code' in params: + raise MissingCodeError("Missing code parameter in response.") + + return params + + +def parse_implicit_response(uri, state=None, scope=None): + """Parse the implicit token response URI into a dict. + + If the resource owner grants the access request, the authorization + server issues an access token and delivers it to the client by adding + the following parameters to the fragment component of the redirection + URI using the ``application/x-www-form-urlencoded`` format: + + **access_token** + REQUIRED. The access token issued by the authorization server. + + **token_type** + REQUIRED. The type of the token issued as described in + Section 7.1. Value is case insensitive. + + **expires_in** + RECOMMENDED. The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + **scope** + OPTIONAL, if identical to the scope requested by the client, + otherwise REQUIRED. The scope of the access token as described + by Section 3.3. + + **state** + REQUIRED if the "state" parameter was present in the client + authorization request. The exact value received from the + client. + + :param uri: + :param state: + :param scope: + + Similar to the authorization code response, but with a full token provided + in the URL fragment: + + .. code-block:: http + + HTTP/1.1 302 Found + Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA + &state=xyz&token_type=example&expires_in=3600 + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + fragment = urlparse.urlparse(uri).fragment + params = dict(urlparse.parse_qsl(fragment, keep_blank_values=True)) + + for key in ('expires_in',): + if key in params: # cast things to int + params[key] = int(params[key]) + + if 'scope' in params: + params['scope'] = scope_to_list(params['scope']) + + if 'expires_in' in params: + params['expires_at'] = time.time() + int(params['expires_in']) + + if state and params.get('state', None) != state: + raise ValueError("Mismatching or missing state in params.") + + params = OAuth2Token(params, old_scope=scope) + validate_token_parameters(params) + return params + + +def parse_token_response(body, scope=None): + """Parse the JSON token response body into a dict. + + The authorization server issues an access token and optional refresh + token, and constructs the response by adding the following parameters + to the entity body of the HTTP response with a 200 (OK) status code: + + access_token + REQUIRED. The access token issued by the authorization server. + token_type + REQUIRED. The type of the token issued as described in + `Section 7.1`_. Value is case insensitive. + expires_in + RECOMMENDED. The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + refresh_token + OPTIONAL. The refresh token which can be used to obtain new + access tokens using the same authorization grant as described + in `Section 6`_. + scope + OPTIONAL, if identical to the scope requested by the client, + otherwise REQUIRED. The scope of the access token as described + by `Section 3.3`_. + + The parameters are included in the entity body of the HTTP response + using the "application/json" media type as defined by [`RFC4627`_]. The + parameters are serialized into a JSON structure by adding each + parameter at the highest structure level. Parameter names and string + values are included as JSON strings. Numerical values are included + as JSON numbers. The order of parameters does not matter and can + vary. + + :param body: The full json encoded response body. + :param scope: The scope requested during authorization. + + For example: + + .. code-block:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + Pragma: no-cache + + { + "access_token":"2YotnFZFEjr1zCsicMWpAA", + "token_type":"example", + "expires_in":3600, + "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter":"example_value" + } + + .. _`Section 7.1`: https://tools.ietf.org/html/rfc6749#section-7.1 + .. _`Section 6`: https://tools.ietf.org/html/rfc6749#section-6 + .. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3 + .. _`RFC4627`: https://tools.ietf.org/html/rfc4627 + """ + try: + params = json.loads(body) + except ValueError: + + # Fall back to URL-encoded string, to support old implementations, + # including (at time of writing) Facebook. See: + # https://github.com/oauthlib/oauthlib/issues/267 + + params = dict(urlparse.parse_qsl(body)) + for key in ('expires_in',): + if key in params: # cast things to int + params[key] = int(params[key]) + + if 'scope' in params: + params['scope'] = scope_to_list(params['scope']) + + if 'expires_in' in params: + if params['expires_in'] is None: + params.pop('expires_in') + else: + params['expires_at'] = time.time() + int(params['expires_in']) + + params = OAuth2Token(params, old_scope=scope) + validate_token_parameters(params) + return params + + +def validate_token_parameters(params): + """Ensures token presence, token type, expiration and scope in params.""" + if 'error' in params: + raise_from_error(params.get('error'), params) + + if not 'access_token' in params: + raise MissingTokenError(description="Missing access token parameter.") + + if not 'token_type' in params: + if os.environ.get('OAUTHLIB_STRICT_TOKEN_TYPE'): + raise MissingTokenTypeError() + + # If the issued access token scope is different from the one requested by + # the client, the authorization server MUST include the "scope" response + # parameter to inform the client of the actual scope granted. + # https://tools.ietf.org/html/rfc6749#section-3.3 + if params.scope_changed: + message = 'Scope has changed from "{old}" to "{new}".'.format( + old=params.old_scope, new=params.scope, + ) + scope_changed.send(message=message, old=params.old_scopes, new=params.scopes) + if not os.environ.get('OAUTHLIB_RELAX_TOKEN_SCOPE', None): + w = Warning(message) + w.token = params + w.old_scope = params.old_scopes + w.new_scope = params.scopes + raise w diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/request_validator.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/request_validator.py new file mode 100644 index 0000000000..3910c0b918 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/request_validator.py @@ -0,0 +1,680 @@ +""" +oauthlib.oauth2.rfc6749.request_validator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +log = logging.getLogger(__name__) + + +class RequestValidator: + + def client_authentication_required(self, request, *args, **kwargs): + """Determine if client authentication is required for current request. + + According to the rfc6749, client authentication is required in the following cases: + - Resource Owner Password Credentials Grant, when Client type is Confidential or when + Client was issued client credentials or whenever Client provided client + authentication, see `Section 4.3.2`_. + - Authorization Code Grant, when Client type is Confidential or when Client was issued + client credentials or whenever Client provided client authentication, + see `Section 4.1.3`_. + - Refresh Token Grant, when Client type is Confidential or when Client was issued + client credentials or whenever Client provided client authentication, see + `Section 6`_ + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant + - Refresh Token Grant + + .. _`Section 4.3.2`: https://tools.ietf.org/html/rfc6749#section-4.3.2 + .. _`Section 4.1.3`: https://tools.ietf.org/html/rfc6749#section-4.1.3 + .. _`Section 6`: https://tools.ietf.org/html/rfc6749#section-6 + """ + return True + + def authenticate_client(self, request, *args, **kwargs): + """Authenticate client through means outside the OAuth 2 spec. + + Means of authentication is negotiated beforehand and may for example + be `HTTP Basic Authentication Scheme`_ which utilizes the Authorization + header. + + Headers may be accesses through request.headers and parameters found in + both body and query can be obtained by direct attribute access, i.e. + request.client_id for client_id in the URL query. + + The authentication process is required to contain the identification of + the client (i.e. search the database based on the client_id). In case the + client doesn't exist based on the received client_id, this method has to + return False and the HTTP response created by the library will contain + 'invalid_client' message. + + After the client identification succeeds, this method needs to set the + client on the request, i.e. request.client = client. A client object's + class must contain the 'client_id' attribute and the 'client_id' must have + a value. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant (may be disabled) + - Client Credentials Grant + - Refresh Token Grant + + .. _`HTTP Basic Authentication Scheme`: https://tools.ietf.org/html/rfc1945#section-11.1 + """ + raise NotImplementedError('Subclasses must implement this method.') + + def authenticate_client_id(self, client_id, request, *args, **kwargs): + """Ensure client_id belong to a non-confidential client. + + A non-confidential client is one that is not required to authenticate + through other means, such as using HTTP Basic. + + Note, while not strictly necessary it can often be very convenient + to set request.client to the client object associated with the + given client_id. + + :param client_id: Unicode client identifier. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, + *args, **kwargs): + """Ensure that the authorization process represented by this authorization + code began with this 'redirect_uri'. + + If the client specifies a redirect_uri when obtaining code then that + redirect URI must be bound to the code and verified equal in this + method, according to RFC 6749 section 4.1.3. Do not compare against + the client's allowed redirect URIs, but against the URI used when the + code was saved. + + :param client_id: Unicode client identifier. + :param code: Unicode authorization_code. + :param redirect_uri: Unicode absolute URI. + :param client: Client object set by you, see ``.authenticate_client``. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant (during token request) + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_default_redirect_uri(self, client_id, request, *args, **kwargs): + """Get the default redirect URI for the client. + + :param client_id: Unicode client identifier. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: The default redirect URI for the client + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_default_scopes(self, client_id, request, *args, **kwargs): + """Get the default scopes for the client. + + :param client_id: Unicode client identifier. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: List of default scopes + + Method is used by all core grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_original_scopes(self, refresh_token, request, *args, **kwargs): + """Get the list of scopes associated with the refresh token. + + :param refresh_token: Unicode refresh token. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: List of scopes. + + Method is used by: + - Refresh token grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def is_within_original_scope(self, request_scopes, refresh_token, request, *args, **kwargs): + """Check if requested scopes are within a scope of the refresh token. + + When access tokens are refreshed the scope of the new token + needs to be within the scope of the original token. This is + ensured by checking that all requested scopes strings are on + the list returned by the get_original_scopes. If this check + fails, is_within_original_scope is called. The method can be + used in situations where returning all valid scopes from the + get_original_scopes is not practical. + + :param request_scopes: A list of scopes that were requested by client. + :param refresh_token: Unicode refresh_token. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Refresh token grant + """ + return False + + def introspect_token(self, token, token_type_hint, request, *args, **kwargs): + """Introspect an access or refresh token. + + Called once the introspect request is validated. This method should + verify the *token* and either return a dictionary with the list of + claims associated, or `None` in case the token is unknown. + + Below the list of registered claims you should be interested in: + + - scope : space-separated list of scopes + - client_id : client identifier + - username : human-readable identifier for the resource owner + - token_type : type of the token + - exp : integer timestamp indicating when this token will expire + - iat : integer timestamp indicating when this token was issued + - nbf : integer timestamp indicating when it can be "not-before" used + - sub : subject of the token - identifier of the resource owner + - aud : list of string identifiers representing the intended audience + - iss : string representing issuer of this token + - jti : string identifier for the token + + Note that most of them are coming directly from JWT RFC. More details + can be found in `Introspect Claims`_ or `JWT Claims`_. + + The implementation can use *token_type_hint* to improve lookup + efficiency, but must fallback to other types to be compliant with RFC. + + The dict of claims is added to request.token after this method. + + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + Method is used by: + - Introspect Endpoint (all grants are compatible) + + .. _`Introspect Claims`: https://tools.ietf.org/html/rfc7662#section-2.2 + .. _`JWT Claims`: https://tools.ietf.org/html/rfc7519#section-4 + """ + raise NotImplementedError('Subclasses must implement this method.') + + def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): + """Invalidate an authorization code after use. + + :param client_id: Unicode client identifier. + :param code: The authorization code grant (request.code). + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): + """Revoke an access or refresh token. + + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + Method is used by: + - Revocation Endpoint + """ + raise NotImplementedError('Subclasses must implement this method.') + + def rotate_refresh_token(self, request): + """Determine whether to rotate the refresh token. Default, yes. + + When access tokens are refreshed the old refresh token can be kept + or replaced with a new one (rotated). Return True to rotate and + and False for keeping original. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Refresh Token Grant + """ + return True + + def save_authorization_code(self, client_id, code, request, *args, **kwargs): + """Persist the authorization_code. + + The code should at minimum be stored with: + - the client_id (``client_id``) + - the redirect URI used (``request.redirect_uri``) + - a resource owner / user (``request.user``) + - the authorized scopes (``request.scopes``) + + To support PKCE, you MUST associate the code with: + - Code Challenge (``request.code_challenge``) and + - Code Challenge Method (``request.code_challenge_method``) + + To support OIDC, you MUST associate the code with: + - nonce, if present (``code["nonce"]``) + + The ``code`` argument is actually a dictionary, containing at least a + ``code`` key with the actual authorization code: + + ``{'code': 'sdf345jsdf0934f'}`` + + It may also have a ``claims`` parameter which, when present, will be a dict + deserialized from JSON as described at + http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter + This value should be saved in this method and used again in ``.validate_code``. + + :param client_id: Unicode client identifier. + :param code: A dict of the authorization code grant and, optionally, state. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def save_token(self, token, request, *args, **kwargs): + """Persist the token with a token type specific method. + + Currently, only save_bearer_token is supported. + + :param token: A (Bearer) token dict. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + return self.save_bearer_token(token, request, *args, **kwargs) + + def save_bearer_token(self, token, request, *args, **kwargs): + """Persist the Bearer token. + + The Bearer token should at minimum be associated with: + - a client and it's client_id, if available + - a resource owner / user (request.user) + - authorized scopes (request.scopes) + - an expiration time + - a refresh token, if issued + - a claims document, if present in request.claims + + The Bearer token dict may hold a number of items:: + + { + 'token_type': 'Bearer', + 'access_token': 'askfjh234as9sd8', + 'expires_in': 3600, + 'scope': 'string of space separated authorized scopes', + 'refresh_token': '23sdf876234', # if issued + 'state': 'given_by_client', # if supplied by client (implicit ONLY) + } + + Note that while "scope" is a string-separated list of authorized scopes, + the original list is still available in request.scopes. + + The token dict is passed as a reference so any changes made to the dictionary + will go back to the user. If additional information must return to the client + user, and it is only possible to get this information after writing the token + to storage, it should be added to the token dictionary. If the token + dictionary must be modified but the changes should not go back to the user, + a copy of the dictionary must be made before making the changes. + + Also note that if an Authorization Code grant request included a valid claims + parameter (for OpenID Connect) then the request.claims property will contain + the claims dict, which should be saved for later use when generating the + id_token and/or UserInfo response content. + + :param token: A Bearer token dict. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: The default redirect URI for the client + + Method is used by all core grant types issuing Bearer tokens: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant (might not associate a client) + - Client Credentials grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_bearer_token(self, token, scopes, request): + """Ensure the Bearer token is valid and authorized access to scopes. + + :param token: A string of random characters. + :param scopes: A list of scopes associated with the protected resource. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + + A key to OAuth 2 security and restricting impact of leaked tokens is + the short expiration time of tokens, *always ensure the token has not + expired!*. + + Two different approaches to scope validation: + + 1) all(scopes). The token must be authorized access to all scopes + associated with the resource. For example, the + token has access to ``read-only`` and ``images``, + thus the client can view images but not upload new. + Allows for fine grained access control through + combining various scopes. + + 2) any(scopes). The token must be authorized access to one of the + scopes associated with the resource. For example, + token has access to ``read-only-images``. + Allows for fine grained, although arguably less + convenient, access control. + + A powerful way to use scopes would mimic UNIX ACLs and see a scope + as a group with certain privileges. For a restful API these might + map to HTTP verbs instead of read, write and execute. + + Note, the request.user attribute can be set to the resource owner + associated with this token. Similarly the request.client and + request.scopes attribute can be set to associated client object + and authorized scopes. If you then use a decorator such as the + one provided for django these attributes will be made available + in all protected views as keyword arguments. + + :param token: Unicode Bearer token + :param scopes: List of scopes (defined by you) + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is indirectly used by all core Bearer token issuing grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_client_id(self, client_id, request, *args, **kwargs): + """Ensure client_id belong to a valid and active client. + + Note, while not strictly necessary it can often be very convenient + to set request.client to the client object associated with the + given client_id. + + :param client_id: Unicode client identifier. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + """Verify that the authorization_code is valid and assigned to the given + client. + + Before returning true, set the following based on the information stored + with the code in 'save_authorization_code': + + - request.user + - request.scopes + - request.claims (if given) + + OBS! The request.user attribute should be set to the resource owner + associated with this authorization code. Similarly request.scopes + must also be set. + + The request.claims property, if it was given, should assigned a dict. + + If PKCE is enabled (see 'is_pkce_required' and 'save_authorization_code') + you MUST set the following based on the information stored: + + - request.code_challenge + - request.code_challenge_method + + :param client_id: Unicode client identifier. + :param code: Unicode authorization code. + :param client: Client object set by you, see ``.authenticate_client``. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + """Ensure client is authorized to use the grant_type requested. + + :param client_id: Unicode client identifier. + :param grant_type: Unicode grant type, i.e. authorization_code, password. + :param client: Client object set by you, see ``.authenticate_client``. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + - Refresh Token Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): + """Ensure client is authorized to redirect to the redirect_uri requested. + + All clients should register the absolute URIs of all URIs they intend + to redirect to. The registration is outside of the scope of oauthlib. + + :param client_id: Unicode client identifier. + :param redirect_uri: Unicode absolute URI. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): + """Ensure the Bearer token is valid and authorized access to scopes. + + OBS! The request.user attribute should be set to the resource owner + associated with this refresh token. + + :param refresh_token: Unicode refresh token. + :param client: Client object set by you, see ``.authenticate_client``. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant (indirectly by issuing refresh tokens) + - Resource Owner Password Credentials Grant (also indirectly) + - Refresh Token Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): + """Ensure client is authorized to use the response_type requested. + + :param client_id: Unicode client identifier. + :param response_type: Unicode response type, i.e. code, token. + :param client: Client object set by you, see ``.authenticate_client``. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + """Ensure the client is authorized access to requested scopes. + + :param client_id: Unicode client identifier. + :param scopes: List of scopes (defined by you). + :param client: Client object set by you, see ``.authenticate_client``. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by all core grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_user(self, username, password, client, request, *args, **kwargs): + """Ensure the username and password is valid. + + OBS! The validation should also set the user attribute of the request + to a valid resource owner, i.e. request.user = username or similar. If + not set you will be unable to associate a token with a user in the + persistence method used (commonly, save_bearer_token). + + :param username: Unicode username. + :param password: Unicode password. + :param client: Client object set by you, see ``.authenticate_client``. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Resource Owner Password Credentials Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def is_pkce_required(self, client_id, request): + """Determine if current request requires PKCE. Default, False. + This is called for both "authorization" and "token" requests. + + Override this method by ``return True`` to enable PKCE for everyone. + You might want to enable it only for public clients. + Note that PKCE can also be used in addition of a client authentication. + + OAuth 2.0 public clients utilizing the Authorization Code Grant are + susceptible to the authorization code interception attack. This + specification describes the attack as well as a technique to mitigate + against the threat through the use of Proof Key for Code Exchange + (PKCE, pronounced "pixy"). See `RFC7636`_. + + :param client_id: Client identifier. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + + .. _`RFC7636`: https://tools.ietf.org/html/rfc7636 + """ + return False + + def get_code_challenge(self, code, request): + """Is called for every "token" requests. + + When the server issues the authorization code in the authorization + response, it MUST associate the ``code_challenge`` and + ``code_challenge_method`` values with the authorization code so it can + be verified later. + + Typically, the ``code_challenge`` and ``code_challenge_method`` values + are stored in encrypted form in the ``code`` itself but could + alternatively be stored on the server associated with the code. The + server MUST NOT include the ``code_challenge`` value in client requests + in a form that other entities can extract. + + Return the ``code_challenge`` associated to the code. + If ``None`` is returned, code is considered to not be associated to any + challenges. + + :param code: Authorization code. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: code_challenge string + + Method is used by: + - Authorization Code Grant - when PKCE is active + + """ + return None + + def get_code_challenge_method(self, code, request): + """Is called during the "token" request processing, when a + ``code_verifier`` and a ``code_challenge`` has been provided. + + See ``.get_code_challenge``. + + Must return ``plain`` or ``S256``. You can return a custom value if you have + implemented your own ``AuthorizationCodeGrant`` class. + + :param code: Authorization code. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: code_challenge_method string + + Method is used by: + - Authorization Code Grant - when PKCE is active + + """ + 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 + - Refresh Token Grant + + """ + return False diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/tokens.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/tokens.py new file mode 100644 index 0000000000..0757d07ea5 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/tokens.py @@ -0,0 +1,356 @@ +""" +oauthlib.oauth2.rfc6749.tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains methods for adding two types of access tokens to requests. + +- Bearer https://tools.ietf.org/html/rfc6750 +- MAC https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01 +""" +import hashlib +import hmac +import warnings +from binascii import b2a_base64 +from urllib.parse import urlparse + +from oauthlib import common +from oauthlib.common import add_params_to_qs, add_params_to_uri + +from . import utils + + +class OAuth2Token(dict): + + def __init__(self, params, old_scope=None): + super().__init__(params) + self._new_scope = None + if 'scope' in params and params['scope']: + self._new_scope = set(utils.scope_to_list(params['scope'])) + if old_scope is not None: + self._old_scope = set(utils.scope_to_list(old_scope)) + if self._new_scope is None: + # the rfc says that if the scope hasn't changed, it's optional + # in params so set the new scope to the old scope + self._new_scope = self._old_scope + else: + self._old_scope = self._new_scope + + @property + def scope_changed(self): + return self._new_scope != self._old_scope + + @property + def old_scope(self): + return utils.list_to_scope(self._old_scope) + + @property + def old_scopes(self): + return list(self._old_scope) + + @property + def scope(self): + return utils.list_to_scope(self._new_scope) + + @property + def scopes(self): + return list(self._new_scope) + + @property + def missing_scopes(self): + return list(self._old_scope - self._new_scope) + + @property + def additional_scopes(self): + return list(self._new_scope - self._old_scope) + + +def prepare_mac_header(token, uri, key, http_method, + nonce=None, + headers=None, + body=None, + ext='', + hash_algorithm='hmac-sha-1', + issue_time=None, + draft=0): + """Add an `MAC Access Authentication`_ signature to headers. + + Unlike OAuth 1, this HMAC signature does not require inclusion of the + request payload/body, neither does it use a combination of client_secret + and token_secret but rather a mac_key provided together with the access + token. + + Currently two algorithms are supported, "hmac-sha-1" and "hmac-sha-256", + `extension algorithms`_ are not supported. + + Example MAC Authorization header, linebreaks added for clarity + + Authorization: MAC id="h480djs93hd8", + nonce="1336363200:dj83hs9s", + mac="bhCQXTVyfj5cmA9uKkPFx1zeOXM=" + + .. _`MAC Access Authentication`: https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01 + .. _`extension algorithms`: https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-7.1 + + :param token: + :param uri: Request URI. + :param key: MAC given provided by token endpoint. + :param http_method: HTTP Request method. + :param nonce: + :param headers: Request headers as a dictionary. + :param body: + :param ext: + :param hash_algorithm: HMAC algorithm provided by token endpoint. + :param issue_time: Time when the MAC credentials were issued (datetime). + :param draft: MAC authentication specification version. + :return: headers dictionary with the authorization field added. + """ + http_method = http_method.upper() + host, port = utils.host_from_uri(uri) + + if hash_algorithm.lower() == 'hmac-sha-1': + h = hashlib.sha1 + elif hash_algorithm.lower() == 'hmac-sha-256': + h = hashlib.sha256 + else: + raise ValueError('unknown hash algorithm') + + if draft == 0: + nonce = nonce or '{}:{}'.format(utils.generate_age(issue_time), + common.generate_nonce()) + else: + ts = common.generate_timestamp() + nonce = common.generate_nonce() + + sch, net, path, par, query, fra = urlparse(uri) + + if query: + request_uri = path + '?' + query + else: + request_uri = path + + # Hash the body/payload + if body is not None and draft == 0: + body = body.encode('utf-8') + bodyhash = b2a_base64(h(body).digest())[:-1].decode('utf-8') + else: + bodyhash = '' + + # Create the normalized base string + base = [] + if draft == 0: + base.append(nonce) + else: + base.append(ts) + base.append(nonce) + base.append(http_method.upper()) + base.append(request_uri) + base.append(host) + base.append(port) + if draft == 0: + base.append(bodyhash) + base.append(ext or '') + base_string = '\n'.join(base) + '\n' + + # hmac struggles with unicode strings - http://bugs.python.org/issue5285 + if isinstance(key, str): + key = key.encode('utf-8') + sign = hmac.new(key, base_string.encode('utf-8'), h) + sign = b2a_base64(sign.digest())[:-1].decode('utf-8') + + header = [] + header.append('MAC id="%s"' % token) + if draft != 0: + header.append('ts="%s"' % ts) + header.append('nonce="%s"' % nonce) + if bodyhash: + header.append('bodyhash="%s"' % bodyhash) + if ext: + header.append('ext="%s"' % ext) + header.append('mac="%s"' % sign) + + headers = headers or {} + headers['Authorization'] = ', '.join(header) + return headers + + +def prepare_bearer_uri(token, uri): + """Add a `Bearer Token`_ to the request URI. + Not recommended, use only if client can't use authorization header or body. + + http://www.example.com/path?access_token=h480djs93hd8 + + .. _`Bearer Token`: https://tools.ietf.org/html/rfc6750 + + :param token: + :param uri: + """ + return add_params_to_uri(uri, [(('access_token', token))]) + + +def prepare_bearer_headers(token, headers=None): + """Add a `Bearer Token`_ to the request URI. + Recommended method of passing bearer tokens. + + Authorization: Bearer h480djs93hd8 + + .. _`Bearer Token`: https://tools.ietf.org/html/rfc6750 + + :param token: + :param headers: + """ + headers = headers or {} + headers['Authorization'] = 'Bearer %s' % token + return headers + + +def prepare_bearer_body(token, body=''): + """Add a `Bearer Token`_ to the request body. + + access_token=h480djs93hd8 + + .. _`Bearer Token`: https://tools.ietf.org/html/rfc6750 + + :param token: + :param body: + """ + return add_params_to_qs(body, [(('access_token', token))]) + + +def random_token_generator(request, refresh_token=False): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param refresh_token: + """ + return common.generate_token() + + +def signed_token_generator(private_pem, **kwargs): + """ + :param private_pem: + """ + def signed_token_generator(request): + request.claims = kwargs + return common.generate_signed_token(private_pem, request) + + return signed_token_generator + + +def get_token_from_header(request): + """ + Helper function to extract a token from the request header. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :return: Return the token or None if the Authorization header is malformed. + """ + token = None + + if 'Authorization' in request.headers: + split_header = request.headers.get('Authorization').split() + if len(split_header) == 2 and split_header[0].lower() == 'bearer': + token = split_header[1] + else: + token = request.access_token + + return token + + +class TokenBase: + __slots__ = () + + def __call__(self, request, refresh_token=False): + raise NotImplementedError('Subclasses must implement this method.') + + def validate_request(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + raise NotImplementedError('Subclasses must implement this method.') + + def estimate_type(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + raise NotImplementedError('Subclasses must implement this method.') + + +class BearerToken(TokenBase): + __slots__ = ( + 'request_validator', 'token_generator', + 'refresh_token_generator', 'expires_in' + ) + + def __init__(self, request_validator=None, token_generator=None, + expires_in=None, refresh_token_generator=None): + self.request_validator = request_validator + self.token_generator = token_generator or random_token_generator + self.refresh_token_generator = ( + refresh_token_generator or self.token_generator + ) + self.expires_in = expires_in or 3600 + + def create_token(self, request, refresh_token=False, **kwargs): + """ + Create a BearerToken, by default without refresh token. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param refresh_token: + """ + if "save_token" in kwargs: + warnings.warn("`save_token` has been deprecated, it was not called internally." + "If you do, call `request_validator.save_token()` instead.", + DeprecationWarning) + + if callable(self.expires_in): + expires_in = self.expires_in(request) + else: + expires_in = self.expires_in + + request.expires_in = expires_in + + token = { + 'access_token': self.token_generator(request), + 'expires_in': expires_in, + 'token_type': 'Bearer', + } + + # If provided, include - this is optional in some cases https://tools.ietf.org/html/rfc6749#section-3.3 but + # there is currently no mechanism to coordinate issuing a token for only a subset of the requested scopes so + # all tokens issued are for the entire set of requested scopes. + if request.scopes is not None: + token['scope'] = ' '.join(request.scopes) + + if refresh_token: + if (request.refresh_token and + not self.request_validator.rotate_refresh_token(request)): + token['refresh_token'] = request.refresh_token + else: + token['refresh_token'] = self.refresh_token_generator(request) + + token.update(request.extra_credentials or {}) + return OAuth2Token(token) + + def validate_request(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + token = get_token_from_header(request) + return self.request_validator.validate_bearer_token( + token, request.scopes, request) + + def estimate_type(self, request): + """ + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + if request.headers.get('Authorization', '').split(' ')[0].lower() == 'bearer': + return 9 + elif request.access_token is not None: + return 5 + else: + return 0 diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/utils.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/utils.py new file mode 100644 index 0000000000..7dc27b3dff --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc6749/utils.py @@ -0,0 +1,83 @@ +""" +oauthlib.utils +~~~~~~~~~~~~~~ + +This module contains utility methods used by various parts of the OAuth 2 spec. +""" +import datetime +import os +from urllib.parse import quote, urlparse + +from oauthlib.common import urldecode + + +def list_to_scope(scope): + """Convert a list of scopes to a space separated string.""" + if isinstance(scope, str) or scope is None: + return scope + elif isinstance(scope, (set, tuple, list)): + return " ".join([str(s) for s in scope]) + else: + raise ValueError("Invalid scope (%s), must be string, tuple, set, or list." % scope) + + +def scope_to_list(scope): + """Convert a space separated string to a list of scopes.""" + if isinstance(scope, (tuple, list, set)): + return [str(s) for s in scope] + elif scope is None: + return None + else: + return scope.strip().split(" ") + + +def params_from_uri(uri): + params = dict(urldecode(urlparse(uri).query)) + if 'scope' in params: + params['scope'] = scope_to_list(params['scope']) + return params + + +def host_from_uri(uri): + """Extract hostname and port from URI. + + Will use default port for HTTP and HTTPS if none is present in the URI. + """ + default_ports = { + 'HTTP': '80', + 'HTTPS': '443', + } + + sch, netloc, path, par, query, fra = urlparse(uri) + if ':' in netloc: + netloc, port = netloc.split(':', 1) + else: + port = default_ports.get(sch.upper()) + + return netloc, port + + +def escape(u): + """Escape a string in an OAuth-compatible fashion. + + TODO: verify whether this can in fact be used for OAuth 2 + + """ + if not isinstance(u, str): + raise ValueError('Only unicode objects are escapable.') + return quote(u.encode('utf-8'), safe=b'~') + + +def generate_age(issue_time): + """Generate a age parameter for MAC authentication draft 00.""" + td = datetime.datetime.now() - issue_time + age = (td.microseconds + (td.seconds + td.days * 24 * 3600) + * 10 ** 6) / 10 ** 6 + return str(age) + + +def is_secure_transport(uri): + """Check if the uri is over ssl.""" + if os.environ.get('OAUTHLIB_INSECURE_TRANSPORT'): + return True + return uri.lower().startswith('https://') diff --git a/contrib/python/oauthlib/oauthlib/oauth2/rfc8628/__init__.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc8628/__init__.py new file mode 100644 index 0000000000..531929dcc7 --- /dev/null +++ b/contrib/python/oauthlib/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/contrib/python/oauthlib/oauthlib/oauth2/rfc8628/clients/__init__.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 0000000000..130b52e381 --- /dev/null +++ b/contrib/python/oauthlib/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/contrib/python/oauthlib/oauthlib/oauth2/rfc8628/clients/device.py b/contrib/python/oauthlib/oauthlib/oauth2/rfc8628/clients/device.py new file mode 100644 index 0000000000..b9ba2150a2 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/oauth2/rfc8628/clients/device.py @@ -0,0 +1,95 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 Device Authorization RFC8628. +""" +from oauthlib.common import add_params_to_uri +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 + + +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 parameters. 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.2.1`: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1 + .. _`Section 3.3`: https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 + .. _`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/contrib/python/oauthlib/oauthlib/openid/__init__.py b/contrib/python/oauthlib/oauthlib/openid/__init__.py new file mode 100644 index 0000000000..e317437479 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/__init__.py @@ -0,0 +1,7 @@ +""" +oauthlib.openid +~~~~~~~~~~~~~~ + +""" +from .connect.core.endpoints import Server, UserInfoEndpoint +from .connect.core.request_validator import RequestValidator diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/__init__.py b/contrib/python/oauthlib/oauthlib/openid/connect/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/__init__.py diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/__init__.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/__init__.py diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/__init__.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/__init__.py new file mode 100644 index 0000000000..7017ff4f32 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/__init__.py @@ -0,0 +1,9 @@ +""" +oauthlib.oopenid.core +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OpenID Connect +""" +from .pre_configured import Server +from .userinfo import UserInfoEndpoint diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/pre_configured.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/pre_configured.py new file mode 100644 index 0000000000..8ce8bee67b --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/pre_configured.py @@ -0,0 +1,97 @@ +""" +oauthlib.openid.connect.core.endpoints.pre_configured +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various endpoints needed +for providing OpenID Connect servers. +""" +from oauthlib.oauth2.rfc6749.endpoints import ( + AuthorizationEndpoint, IntrospectEndpoint, ResourceEndpoint, + RevocationEndpoint, TokenEndpoint, +) +from oauthlib.oauth2.rfc6749.grant_types import ( + AuthorizationCodeGrant as OAuth2AuthorizationCodeGrant, + ClientCredentialsGrant, ImplicitGrant as OAuth2ImplicitGrant, + RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, +) +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +from ..grant_types import AuthorizationCodeGrant, HybridGrant, ImplicitGrant +from ..grant_types.dispatchers import ( + AuthorizationCodeGrantDispatcher, AuthorizationTokenGrantDispatcher, + ImplicitTokenGrantDispatcher, +) +from ..tokens import JWTToken +from .userinfo import UserInfoEndpoint + + +class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, + ResourceEndpoint, RevocationEndpoint, UserInfoEndpoint): + + """An all-in-one endpoint featuring all four major grant types.""" + + def __init__(self, request_validator, token_expires_in=None, + token_generator=None, refresh_token_generator=None, + *args, **kwargs): + """Construct a new all-grants-in-one server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + self.auth_grant = OAuth2AuthorizationCodeGrant(request_validator) + self.implicit_grant = OAuth2ImplicitGrant(request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant( + request_validator) + self.credentials_grant = ClientCredentialsGrant(request_validator) + self.refresh_grant = RefreshTokenGrant(request_validator) + self.openid_connect_auth = AuthorizationCodeGrant(request_validator) + self.openid_connect_implicit = ImplicitGrant(request_validator) + self.openid_connect_hybrid = HybridGrant(request_validator) + + self.bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + + self.jwt = JWTToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + + self.auth_grant_choice = AuthorizationCodeGrantDispatcher(default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth) + self.implicit_grant_choice = ImplicitTokenGrantDispatcher(default_grant=self.implicit_grant, oidc_grant=self.openid_connect_implicit) + + # See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Combinations for valid combinations + # internally our AuthorizationEndpoint will ensure they can appear in any order for any valid combination + AuthorizationEndpoint.__init__(self, default_response_type='code', + response_types={ + 'code': self.auth_grant_choice, + 'token': self.implicit_grant_choice, + 'id_token': self.openid_connect_implicit, + 'id_token token': self.openid_connect_implicit, + 'code token': self.openid_connect_hybrid, + 'code id_token': self.openid_connect_hybrid, + 'code id_token token': self.openid_connect_hybrid, + 'none': self.auth_grant + }, + default_token_type=self.bearer) + + self.token_grant_choice = AuthorizationTokenGrantDispatcher(request_validator, default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth) + + TokenEndpoint.__init__(self, default_grant_type='authorization_code', + grant_types={ + 'authorization_code': self.token_grant_choice, + 'password': self.password_grant, + 'client_credentials': self.credentials_grant, + 'refresh_token': self.refresh_grant, + }, + default_token_type=self.bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': self.bearer, 'JWT': self.jwt}) + RevocationEndpoint.__init__(self, request_validator) + IntrospectEndpoint.__init__(self, request_validator) + UserInfoEndpoint.__init__(self, request_validator) diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/userinfo.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/userinfo.py new file mode 100644 index 0000000000..7aa2bbe97d --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/endpoints/userinfo.py @@ -0,0 +1,106 @@ +""" +oauthlib.openid.connect.core.endpoints.userinfo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of userinfo endpoint. +""" +import json +import logging + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.endpoints.base import ( + BaseEndpoint, catch_errors_and_unavailability, +) +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +log = logging.getLogger(__name__) + + +class UserInfoEndpoint(BaseEndpoint): + """Authorizes access to userinfo resource. + """ + def __init__(self, request_validator): + self.bearer = BearerToken(request_validator, None, None, None) + self.request_validator = request_validator + BaseEndpoint.__init__(self) + + @catch_errors_and_unavailability + def create_userinfo_response(self, uri, http_method='GET', body=None, headers=None): + """Validate BearerToken and return userinfo from RequestValidator + + The UserInfo Endpoint MUST return a + content-type header to indicate which format is being returned. The + content-type of the HTTP response MUST be application/json if the + response body is a text JSON object; the response body SHOULD be encoded + using UTF-8. + """ + request = Request(uri, http_method, body, headers) + request.scopes = ["openid"] + self.validate_userinfo_request(request) + + claims = self.request_validator.get_userinfo_claims(request) + if claims is None: + log.error('Userinfo MUST have claims for %r.', request) + raise errors.ServerError(status_code=500) + + if isinstance(claims, dict): + resp_headers = { + 'Content-Type': 'application/json' + } + if "sub" not in claims: + log.error('Userinfo MUST have "sub" for %r.', request) + raise errors.ServerError(status_code=500) + body = json.dumps(claims) + elif isinstance(claims, str): + resp_headers = { + 'Content-Type': 'application/jwt' + } + body = claims + else: + log.error('Userinfo return unknown response for %r.', request) + raise errors.ServerError(status_code=500) + log.debug('Userinfo access valid for %r.', request) + return resp_headers, body, 200 + + def validate_userinfo_request(self, request): + """Ensure the request is valid. + + 5.3.1. UserInfo Request + The Client sends the UserInfo Request using either HTTP GET or HTTP + POST. The Access Token obtained from an OpenID Connect Authentication + Request MUST be sent as a Bearer Token, per `Section 2`_ of OAuth 2.0 + Bearer Token Usage [RFC6750]. + + It is RECOMMENDED that the request use the HTTP GET method and the + Access Token be sent using the Authorization header field. + + The following is a non-normative example of a UserInfo Request: + + .. code-block:: http + + GET /userinfo HTTP/1.1 + Host: server.example.com + Authorization: Bearer SlAV32hkKG + + 5.3.3. UserInfo Error Response + When an error condition occurs, the UserInfo Endpoint returns an Error + Response as defined in `Section 3`_ of OAuth 2.0 Bearer Token Usage + [RFC6750]. (HTTP errors unrelated to RFC 6750 are returned to the User + Agent using the appropriate HTTP status code.) + + The following is a non-normative example of a UserInfo Error Response: + + .. code-block:: http + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer error="invalid_token", + error_description="The Access Token expired" + + .. _`Section 2`: https://datatracker.ietf.org/doc/html/rfc6750#section-2 + .. _`Section 3`: https://datatracker.ietf.org/doc/html/rfc6750#section-3 + """ + if not self.bearer.validate_request(request): + raise errors.InvalidTokenError() + if "openid" not in request.scopes: + raise errors.InsufficientScopeError() diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/exceptions.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/exceptions.py new file mode 100644 index 0000000000..099b84e2da --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/exceptions.py @@ -0,0 +1,149 @@ +""" +oauthlib.oauth2.rfc6749.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth 2 clients and providers to represent the spec +defined error responses for all four core grant types. +""" +from oauthlib.oauth2.rfc6749.errors import FatalClientError, OAuth2Error + + +class FatalOpenIDClientError(FatalClientError): + pass + + +class OpenIDClientError(OAuth2Error): + pass + + +class InteractionRequired(OpenIDClientError): + """ + The Authorization Server requires End-User interaction to proceed. + + This error MAY be returned when the prompt parameter value in the + Authentication Request is none, but the Authentication Request cannot be + completed without displaying a user interface for End-User interaction. + """ + error = 'interaction_required' + status_code = 401 + + +class LoginRequired(OpenIDClientError): + """ + The Authorization Server requires End-User authentication. + + This error MAY be returned when the prompt parameter value in the + Authentication Request is none, but the Authentication Request cannot be + completed without displaying a user interface for End-User authentication. + """ + error = 'login_required' + status_code = 401 + + +class AccountSelectionRequired(OpenIDClientError): + """ + The End-User is REQUIRED to select a session at the Authorization Server. + + The End-User MAY be authenticated at the Authorization Server with + different associated accounts, but the End-User did not select a session. + This error MAY be returned when the prompt parameter value in the + Authentication Request is none, but the Authentication Request cannot be + completed without displaying a user interface to prompt for a session to + use. + """ + error = 'account_selection_required' + + +class ConsentRequired(OpenIDClientError): + """ + The Authorization Server requires End-User consent. + + This error MAY be returned when the prompt parameter value in the + Authentication Request is none, but the Authentication Request cannot be + completed without displaying a user interface for End-User consent. + """ + error = 'consent_required' + status_code = 401 + + +class InvalidRequestURI(OpenIDClientError): + """ + The request_uri in the Authorization Request returns an error or + contains invalid data. + """ + error = 'invalid_request_uri' + description = 'The request_uri in the Authorization Request returns an ' \ + 'error or contains invalid data.' + + +class InvalidRequestObject(OpenIDClientError): + """ + The request parameter contains an invalid Request Object. + """ + error = 'invalid_request_object' + description = 'The request parameter contains an invalid Request Object.' + + +class RequestNotSupported(OpenIDClientError): + """ + The OP does not support use of the request parameter. + """ + error = 'request_not_supported' + description = 'The request parameter is not supported.' + + +class RequestURINotSupported(OpenIDClientError): + """ + The OP does not support use of the request_uri parameter. + """ + error = 'request_uri_not_supported' + description = 'The request_uri parameter is not supported.' + + +class RegistrationNotSupported(OpenIDClientError): + """ + The OP does not support use of the registration parameter. + """ + error = 'registration_not_supported' + description = 'The registration parameter is not supported.' + + +class InvalidTokenError(OAuth2Error): + """ + The access token provided is expired, revoked, malformed, or + invalid for other reasons. The resource SHOULD respond with + the HTTP 401 (Unauthorized) status code. The client MAY + request a new access token and retry the protected resource + request. + """ + error = 'invalid_token' + status_code = 401 + description = ("The access token provided is expired, revoked, malformed, " + "or invalid for other reasons.") + + +class InsufficientScopeError(OAuth2Error): + """ + The request requires higher privileges than provided by the + access token. The resource server SHOULD respond with the HTTP + 403 (Forbidden) status code and MAY include the "scope" + attribute with the scope necessary to access the protected + resource. + """ + error = 'insufficient_scope' + status_code = 403 + description = ("The request requires higher privileges than provided by " + "the access token.") + + +def raise_from_error(error, params=None): + import inspect + import sys + kwargs = { + 'description': params.get('error_description'), + 'uri': params.get('error_uri'), + 'state': params.get('state') + } + for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + if cls.error == error: + raise cls(**kwargs) diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/__init__.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/__init__.py new file mode 100644 index 0000000000..8dad5f607b --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/__init__.py @@ -0,0 +1,13 @@ +""" +oauthlib.openid.connect.core.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from .authorization_code import AuthorizationCodeGrant +from .base import GrantTypeBase +from .dispatchers import ( + AuthorizationCodeGrantDispatcher, AuthorizationTokenGrantDispatcher, + ImplicitTokenGrantDispatcher, +) +from .hybrid import HybridGrant +from .implicit import ImplicitGrant +from .refresh_token import RefreshTokenGrant diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/authorization_code.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/authorization_code.py new file mode 100644 index 0000000000..6b2dcc3bdd --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/authorization_code.py @@ -0,0 +1,43 @@ +""" +oauthlib.openid.connect.core.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +from oauthlib.oauth2.rfc6749.grant_types.authorization_code import ( + AuthorizationCodeGrant as OAuth2AuthorizationCodeGrant, +) + +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class AuthorizationCodeGrant(GrantTypeBase): + + def __init__(self, request_validator=None, **kwargs): + self.proxy_target = OAuth2AuthorizationCodeGrant( + request_validator=request_validator, **kwargs) + self.custom_validators.post_auth.append( + self.openid_authorization_validator) + 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. + """ + # Treat it as normal OAuth 2 auth code request if openid is not present + if not request.scopes or 'openid' not in request.scopes: + return token + + nonce = self.request_validator.get_authorization_code_nonce( + request.client_id, + request.code, + request.redirect_uri, + request + ) + return super().add_id_token(token, token_handler, request, nonce=nonce) diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/base.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/base.py new file mode 100644 index 0000000000..33411dad75 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/base.py @@ -0,0 +1,326 @@ +import base64 +import hashlib +import logging +import time +from json import loads + +from oauthlib.oauth2.rfc6749.errors import ( + ConsentRequired, InvalidRequestError, LoginRequired, +) + +log = logging.getLogger(__name__) + + +class GrantTypeBase: + + # Just proxy the majority of method calls through to the + # proxy_target grant type handler, which will usually be either + # the standard OAuth2 AuthCode or Implicit grant types. + def __getattr__(self, attr): + return getattr(self.proxy_target, attr) + + def __setattr__(self, attr, value): + proxied_attrs = {'refresh_token', 'response_types'} + if attr in proxied_attrs: + setattr(self.proxy_target, attr, value) + else: + super(OpenIDConnectBase, self).__setattr__(attr, value) + + def validate_authorization_request(self, request): + """Validates the OpenID Connect authorization request parameters. + + :returns: (list of scopes, dict of request info) + """ + return self.proxy_target.validate_authorization_request(request) + + def _inflate_claims(self, request): + # this may be called multiple times in a single request so make sure we only de-serialize the claims once + if request.claims and not isinstance(request.claims, dict): + # specific claims are requested during the Authorization Request and may be requested for inclusion + # in either the id_token or the UserInfo endpoint response + # see http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter + try: + request.claims = loads(request.claims) + except Exception as ex: + raise InvalidRequestError(description="Malformed claims parameter", + uri="http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter") + + def id_token_hash(self, value, hashfunc=hashlib.sha256): + """ + Its value is the base64url encoding of the left-most half of the + hash of the octets of the ASCII representation of the access_token + value, where the hash algorithm used is the hash algorithm used in + the alg Header Parameter of the ID Token's JOSE Header. + + For instance, if the alg is RS256, hash the access_token value + with SHA-256, then take the left-most 128 bits and + base64url-encode them. + For instance, if the alg is HS512, hash the code value with + SHA-512, then take the left-most 256 bits and base64url-encode + them. The c_hash value is a case-sensitive string. + + Example of hash from OIDC specification (bound to a JWS using RS256): + + code: + Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk + + c_hash: + LDktKdoQak3Pk0cnXxCltA + """ + digest = hashfunc(value.encode()).digest() + left_most = len(digest) // 2 + return base64.urlsafe_b64encode(digest[:left_most]).decode().rstrip("=") + + def add_id_token(self, token, token_handler, request, nonce=None): + """ + Construct an initial version of id_token, and let the + request_validator sign or encrypt it. + + The initial version can contain the fields below, accordingly + to the spec: + - aud + - iat + - nonce + - at_hash + - c_hash + """ + # Treat it as normal OAuth 2 auth code request if openid is not present + if not request.scopes or 'openid' not in request.scopes: + return token + + # Only add an id token on auth/token step if asked for. + if request.response_type and 'id_token' not in request.response_type: + return token + + # Implementation mint its own id_token without help. + id_token = self.request_validator.get_id_token(token, token_handler, request) + if id_token: + token['id_token'] = id_token + return token + + # Fallback for asking some help from oauthlib framework. + # Start with technicals fields bound to the specification. + id_token = {} + id_token['aud'] = request.client_id + id_token['iat'] = int(time.time()) + + # nonce is REQUIRED when response_type value is: + # - id_token token (Implicit) + # - id_token (Implicit) + # - code id_token (Hybrid) + # - code id_token token (Hybrid) + # + # nonce is OPTIONAL when response_type value is: + # - code (Authorization Code) + # - code token (Hybrid) + if nonce is not None: + id_token["nonce"] = nonce + + # at_hash is REQUIRED when response_type value is: + # - id_token token (Implicit) + # - code id_token token (Hybrid) + # + # at_hash is OPTIONAL when: + # - code (Authorization code) + # - code id_token (Hybrid) + # - code token (Hybrid) + # + # at_hash MAY NOT be used when: + # - id_token (Implicit) + if "access_token" in token: + id_token["at_hash"] = self.id_token_hash(token["access_token"]) + + # c_hash is REQUIRED when response_type value is: + # - code id_token (Hybrid) + # - code id_token token (Hybrid) + # + # c_hash is OPTIONAL for others. + if "code" in token: + id_token["c_hash"] = self.id_token_hash(token["code"]) + + # Call request_validator to complete/sign/encrypt id_token + token['id_token'] = self.request_validator.finalize_id_token(id_token, token, token_handler, request) + + return token + + def openid_authorization_validator(self, request): + """Perform OpenID Connect specific authorization request validation. + + nonce + OPTIONAL. String value used to associate a Client session with + an ID Token, and to mitigate replay attacks. The value is + passed through unmodified from the Authentication Request to + the ID Token. Sufficient entropy MUST be present in the nonce + values used to prevent attackers from guessing values + + display + OPTIONAL. ASCII string value that specifies how the + Authorization Server displays the authentication and consent + user interface pages to the End-User. The defined values are: + + page - The Authorization Server SHOULD display the + authentication and consent UI consistent with a full User + Agent page view. If the display parameter is not specified, + this is the default display mode. + + popup - The Authorization Server SHOULD display the + authentication and consent UI consistent with a popup User + Agent window. The popup User Agent window should be of an + appropriate size for a login-focused dialog and should not + obscure the entire window that it is popping up over. + + touch - The Authorization Server SHOULD display the + authentication and consent UI consistent with a device that + leverages a touch interface. + + wap - The Authorization Server SHOULD display the + authentication and consent UI consistent with a "feature + phone" type display. + + The Authorization Server MAY also attempt to detect the + capabilities of the User Agent and present an appropriate + display. + + prompt + OPTIONAL. Space delimited, case sensitive list of ASCII string + values that specifies whether the Authorization Server prompts + the End-User for reauthentication and consent. The defined + values are: + + none - The Authorization Server MUST NOT display any + authentication or consent user interface pages. An error is + returned if an End-User is not already authenticated or the + Client does not have pre-configured consent for the + requested Claims or does not fulfill other conditions for + processing the request. The error code will typically be + login_required, interaction_required, or another code + defined in Section 3.1.2.6. This can be used as a method to + check for existing authentication and/or consent. + + login - The Authorization Server SHOULD prompt the End-User + for reauthentication. If it cannot reauthenticate the + End-User, it MUST return an error, typically + login_required. + + consent - The Authorization Server SHOULD prompt the + End-User for consent before returning information to the + Client. If it cannot obtain consent, it MUST return an + error, typically consent_required. + + select_account - The Authorization Server SHOULD prompt the + End-User to select a user account. This enables an End-User + who has multiple accounts at the Authorization Server to + select amongst the multiple accounts that they might have + current sessions for. If it cannot obtain an account + selection choice made by the End-User, it MUST return an + error, typically account_selection_required. + + The prompt parameter can be used by the Client to make sure + that the End-User is still present for the current session or + to bring attention to the request. If this parameter contains + none with any other value, an error is returned. + + max_age + OPTIONAL. Maximum Authentication Age. Specifies the allowable + elapsed time in seconds since the last time the End-User was + actively authenticated by the OP. If the elapsed time is + greater than this value, the OP MUST attempt to actively + re-authenticate the End-User. (The max_age request parameter + corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] max_auth_age + request parameter.) When max_age is used, the ID Token returned + MUST include an auth_time Claim Value. + + ui_locales + OPTIONAL. End-User's preferred languages and scripts for the + user interface, represented as a space-separated list of BCP47 + [RFC5646] language tag values, ordered by preference. For + instance, the value "fr-CA fr en" represents a preference for + French as spoken in Canada, then French (without a region + designation), followed by English (without a region + designation). An error SHOULD NOT result if some or all of the + requested locales are not supported by the OpenID Provider. + + id_token_hint + OPTIONAL. ID Token previously issued by the Authorization + Server being passed as a hint about the End-User's current or + past authenticated session with the Client. If the End-User + identified by the ID Token is logged in or is logged in by the + request, then the Authorization Server returns a positive + response; otherwise, it SHOULD return an error, such as + login_required. When possible, an id_token_hint SHOULD be + present when prompt=none is used and an invalid_request error + MAY be returned if it is not; however, the server SHOULD + respond successfully when possible, even if it is not present. + The Authorization Server need not be listed as an audience of + the ID Token when it is used as an id_token_hint value. If the + ID Token received by the RP from the OP is encrypted, to use it + as an id_token_hint, the Client MUST decrypt the signed ID + Token contained within the encrypted ID Token. The Client MAY + re-encrypt the signed ID token to the Authentication Server + using a key that enables the server to decrypt the ID Token, + and use the re-encrypted ID token as the id_token_hint value. + + login_hint + OPTIONAL. Hint to the Authorization Server about the login + identifier the End-User might use to log in (if necessary). + This hint can be used by an RP if it first asks the End-User + for their e-mail address (or other identifier) and then wants + to pass that value as a hint to the discovered authorization + service. It is RECOMMENDED that the hint value match the value + used for discovery. This value MAY also be a phone number in + the format specified for the phone_number Claim. The use of + this parameter is left to the OP's discretion. + + acr_values + OPTIONAL. Requested Authentication Context Class Reference + values. Space-separated string that specifies the acr values + that the Authorization Server is being requested to use for + processing this Authentication Request, with the values + appearing in order of preference. The Authentication Context + Class satisfied by the authentication performed is returned as + the acr Claim Value, as specified in Section 2. The acr Claim + is requested as a Voluntary Claim by this parameter. + """ + + # Treat it as normal OAuth 2 auth code request if openid is not present + if not request.scopes or 'openid' not in request.scopes: + return {} + + prompt = request.prompt if request.prompt else [] + if hasattr(prompt, 'split'): + prompt = prompt.strip().split() + prompt = set(prompt) + + if 'none' in prompt: + + if len(prompt) > 1: + msg = "Prompt none is mutually exclusive with other values." + raise InvalidRequestError(request=request, description=msg) + + if not self.request_validator.validate_silent_login(request): + raise LoginRequired(request=request) + + if not self.request_validator.validate_silent_authorization(request): + raise ConsentRequired(request=request) + + self._inflate_claims(request) + + if not self.request_validator.validate_user_match( + request.id_token_hint, request.scopes, request.claims, request): + msg = "Session user does not match client supplied user." + raise LoginRequired(request=request, description=msg) + + request_info = { + 'display': request.display, + 'nonce': request.nonce, + 'prompt': prompt, + 'ui_locales': request.ui_locales.split() if request.ui_locales else [], + 'id_token_hint': request.id_token_hint, + 'login_hint': request.login_hint, + 'claims': request.claims + } + + return request_info + + +OpenIDConnectBase = GrantTypeBase diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/dispatchers.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/dispatchers.py new file mode 100644 index 0000000000..5aa7d4698b --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/dispatchers.py @@ -0,0 +1,101 @@ +import logging + +log = logging.getLogger(__name__) + + +class Dispatcher: + default_grant = None + oidc_grant = None + + +class AuthorizationCodeGrantDispatcher(Dispatcher): + """ + This is an adapter class that will route simple Authorization Code + requests, those that have `response_type=code` and a scope including + `openid` to either the `default_grant` or the `oidc_grant` based on + the scopes requested. + """ + def __init__(self, default_grant=None, oidc_grant=None): + self.default_grant = default_grant + self.oidc_grant = oidc_grant + + def _handler_for_request(self, request): + handler = self.default_grant + + if request.scopes and "openid" in request.scopes: + handler = self.oidc_grant + + log.debug('Selecting handler for request %r.', handler) + return handler + + def create_authorization_response(self, request, token_handler): + """Read scope and route to the designated handler.""" + return self._handler_for_request(request).create_authorization_response(request, token_handler) + + def validate_authorization_request(self, request): + """Read scope and route to the designated handler.""" + return self._handler_for_request(request).validate_authorization_request(request) + + +class ImplicitTokenGrantDispatcher(Dispatcher): + """ + This is an adapter class that will route simple Authorization + requests, those that have `id_token` in `response_type` and a scope + including `openid` to either the `default_grant` or the `oidc_grant` + based on the scopes requested. + """ + def __init__(self, default_grant=None, oidc_grant=None): + self.default_grant = default_grant + self.oidc_grant = oidc_grant + + def _handler_for_request(self, request): + handler = self.default_grant + + if request.scopes and "openid" in request.scopes and 'id_token' in request.response_type: + handler = self.oidc_grant + + log.debug('Selecting handler for request %r.', handler) + return handler + + def create_authorization_response(self, request, token_handler): + """Read scope and route to the designated handler.""" + return self._handler_for_request(request).create_authorization_response(request, token_handler) + + def validate_authorization_request(self, request): + """Read scope and route to the designated handler.""" + return self._handler_for_request(request).validate_authorization_request(request) + + +class AuthorizationTokenGrantDispatcher(Dispatcher): + """ + This is an adapter class that will route simple Token requests, those that authorization_code have a scope + including 'openid' to either the default_grant or the oidc_grant based on the scopes requested. + """ + def __init__(self, request_validator, default_grant=None, oidc_grant=None): + self.default_grant = default_grant + self.oidc_grant = oidc_grant + self.request_validator = request_validator + + def _handler_for_request(self, request): + handler = self.default_grant + scopes = () + parameters = dict(request.decoded_body) + client_id = parameters.get('client_id', None) + code = parameters.get('code', None) + redirect_uri = parameters.get('redirect_uri', None) + + # If code is not present fallback to `default_grant` which will + # raise an error for the missing `code` in `create_token_response` step. + if code: + scopes = self.request_validator.get_authorization_code_scopes(client_id, code, redirect_uri, request) + + if 'openid' in scopes: + handler = self.oidc_grant + + log.debug('Selecting handler for request %r.', handler) + return handler + + def create_token_response(self, request, token_handler): + """Read scope and route to the designated handler.""" + handler = self._handler_for_request(request) + return handler.create_token_response(request, token_handler) diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/hybrid.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/hybrid.py new file mode 100644 index 0000000000..7cb0758b81 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/hybrid.py @@ -0,0 +1,63 @@ +""" +oauthlib.openid.connect.core.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +from oauthlib.oauth2.rfc6749.errors import InvalidRequestError +from oauthlib.oauth2.rfc6749.grant_types.authorization_code import ( + AuthorizationCodeGrant as OAuth2AuthorizationCodeGrant, +) + +from ..request_validator import RequestValidator +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class HybridGrant(GrantTypeBase): + + def __init__(self, request_validator=None, **kwargs): + self.request_validator = request_validator or RequestValidator() + + self.proxy_target = OAuth2AuthorizationCodeGrant( + request_validator=request_validator, **kwargs) + # All hybrid response types should be fragment-encoded. + self.proxy_target.default_response_mode = "fragment" + self.register_response_type('code id_token') + self.register_response_type('code token') + self.register_response_type('code id_token token') + self.custom_validators.post_auth.append( + self.openid_authorization_validator) + # Hybrid flows can return the id_token from the authorization + # endpoint as part of the 'code' response + self.register_code_modifier(self.add_token) + self.register_code_modifier(self.add_id_token) + self.register_token_modifier(self.add_id_token) + + def add_id_token(self, token, token_handler, request): + return super().add_id_token(token, token_handler, request, nonce=request.nonce) + + def openid_authorization_validator(self, request): + """Additional validation when following the Authorization Code flow. + """ + request_info = super().openid_authorization_validator(request) + if not request_info: # returns immediately if OAuth2.0 + return request_info + + # REQUIRED if the Response Type of the request is `code + # id_token` or `code id_token token` and OPTIONAL when the + # Response Type of the request is `code token`. It is a string + # value used to associate a Client session with an ID Token, + # and to mitigate replay attacks. The value is passed through + # unmodified from the Authentication Request to the ID + # Token. Sufficient entropy MUST be present in the `nonce` + # values used to prevent attackers from guessing values. For + # implementation notes, see Section 15.5.2. + if request.response_type in ["code id_token", "code id_token token"]: + if not request.nonce: + raise InvalidRequestError( + request=request, + description='Request is missing mandatory nonce parameter.' + ) + return request_info diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/implicit.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/implicit.py new file mode 100644 index 0000000000..a4fe6049bc --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/implicit.py @@ -0,0 +1,51 @@ +""" +oauthlib.openid.connect.core.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +from oauthlib.oauth2.rfc6749.errors import InvalidRequestError +from oauthlib.oauth2.rfc6749.grant_types.implicit import ( + ImplicitGrant as OAuth2ImplicitGrant, +) + +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class ImplicitGrant(GrantTypeBase): + + def __init__(self, request_validator=None, **kwargs): + self.proxy_target = OAuth2ImplicitGrant( + request_validator=request_validator, **kwargs) + self.register_response_type('id_token') + self.register_response_type('id_token token') + self.custom_validators.post_auth.append( + self.openid_authorization_validator) + self.register_token_modifier(self.add_id_token) + + def add_id_token(self, token, token_handler, request): + if 'state' not in token and request.state: + token['state'] = request.state + return super().add_id_token(token, token_handler, request, nonce=request.nonce) + + def openid_authorization_validator(self, request): + """Additional validation when following the implicit flow. + """ + request_info = super().openid_authorization_validator(request) + if not request_info: # returns immediately if OAuth2.0 + return request_info + + # REQUIRED. String value used to associate a Client session with an ID + # Token, and to mitigate replay attacks. The value is passed through + # unmodified from the Authentication Request to the ID Token. + # Sufficient entropy MUST be present in the nonce values used to + # prevent attackers from guessing values. For implementation notes, see + # Section 15.5.2. + if not request.nonce: + raise InvalidRequestError( + request=request, + description='Request is missing mandatory nonce parameter.' + ) + return request_info diff --git a/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/refresh_token.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/grant_types/refresh_token.py new file mode 100644 index 0000000000..43e4499c53 --- /dev/null +++ b/contrib/python/oauthlib/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/contrib/python/oauthlib/oauthlib/openid/connect/core/request_validator.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/request_validator.py new file mode 100644 index 0000000000..47c4cd9406 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/request_validator.py @@ -0,0 +1,320 @@ +""" +oauthlib.openid.connect.core.request_validator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +from oauthlib.oauth2.rfc6749.request_validator import ( + RequestValidator as OAuth2RequestValidator, +) + +log = logging.getLogger(__name__) + + +class RequestValidator(OAuth2RequestValidator): + + def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): + """ Extracts scopes from saved authorization code. + + The scopes returned by this method is used to route token requests + based on scopes passed to Authorization Code requests. + + With that the token endpoint knows when to include OpenIDConnect + id_token in token response only based on authorization code scopes. + + Only code param should be sufficient to retrieve grant code from + any storage you are using, `client_id` and `redirect_uri` can have a + blank value `""` don't forget to check it before using those values + in a select query if a database is used. + + :param client_id: Unicode client identifier + :param code: Unicode authorization code grant + :param redirect_uri: Unicode absolute URI + :return: A list of scope + + Method is used by: + - Authorization Token Grant Dispatcher + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): + """ Extracts nonce from saved authorization code. + + If present in the Authentication Request, Authorization + Servers MUST include a nonce Claim in the ID Token with the + Claim Value being the nonce value sent in the Authentication + Request. Authorization Servers SHOULD perform no other + processing on nonce values used. The nonce value is a + case-sensitive string. + + Only code param should be sufficient to retrieve grant code from + any storage you are using. However, `client_id` and `redirect_uri` + have been validated and can be used also. + + :param client_id: Unicode client identifier + :param code: Unicode authorization code grant + :param redirect_uri: Unicode absolute URI + :return: Unicode nonce + + Method is used by: + - Authorization Token Grant Dispatcher + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_jwt_bearer_token(self, token, token_handler, request): + """Get JWT Bearer token or OpenID Connect ID token + + If using OpenID Connect this SHOULD call `oauthlib.oauth2.RequestValidator.get_id_token` + + :param token: A Bearer token dict + :param token_handler: the token handler (BearerToken class) + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :return: The JWT Bearer token or OpenID Connect ID token (a JWS signed JWT) + + Method is used by JWT Bearer and OpenID Connect tokens: + - JWTToken.create_token + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_id_token(self, token, token_handler, request): + """Get OpenID Connect ID token + + This method is OPTIONAL and is NOT RECOMMENDED. + `finalize_id_token` SHOULD be implemented instead. However, if you + want a full control over the minting of the `id_token`, you + MAY want to override `get_id_token` instead of using + `finalize_id_token`. + + In the OpenID Connect workflows when an ID Token is requested this method is called. + Subclasses should implement the construction, signing and optional encryption of the + ID Token as described in the OpenID Connect spec. + + In addition to the standard OAuth2 request properties, the request may also contain + these OIDC specific properties which are useful to this method: + + - nonce, if workflow is implicit or hybrid and it was provided + - claims, if provided to the original Authorization Code request + + The token parameter is a dict which may contain an ``access_token`` entry, in which + case the resulting ID Token *should* include a calculated ``at_hash`` claim. + + Similarly, when the request parameter has a ``code`` property defined, the ID Token + *should* include a calculated ``c_hash`` claim. + + http://openid.net/specs/openid-connect-core-1_0.html (sections `3.1.3.6`_, `3.2.2.10`_, `3.3.2.11`_) + + .. _`3.1.3.6`: http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken + .. _`3.2.2.10`: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken + .. _`3.3.2.11`: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken + + :param token: A Bearer token dict + :param token_handler: the token handler (BearerToken class) + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :return: The ID Token (a JWS signed JWT) + """ + return None + + def finalize_id_token(self, id_token, token, token_handler, request): + """Finalize OpenID Connect ID token & Sign or Encrypt. + + In the OpenID Connect workflows when an ID Token is requested + this method is called. Subclasses should implement the + construction, signing and optional encryption of the ID Token + as described in the OpenID Connect spec. + + The `id_token` parameter is a dict containing a couple of OIDC + technical fields related to the specification. Prepopulated + attributes are: + + - `aud`, equals to `request.client_id`. + - `iat`, equals to current time. + - `nonce`, if present, is equals to the `nonce` from the + authorization request. + - `at_hash`, hash of `access_token`, if relevant. + - `c_hash`, hash of `code`, if relevant. + + This method MUST provide required fields as below: + + - `iss`, REQUIRED. Issuer Identifier for the Issuer of the response. + - `sub`, REQUIRED. Subject Identifier + - `exp`, REQUIRED. Expiration time on or after which the ID + Token MUST NOT be accepted by the RP when performing + authentication with the OP. + + Additionals claims must be added, note that `request.scope` + should be used to determine the list of claims. + + More information can be found at `OpenID Connect Core#Claims`_ + + .. _`OpenID Connect Core#Claims`: https://openid.net/specs/openid-connect-core-1_0.html#Claims + + :param id_token: A dict containing technical fields of id_token + :param token: A Bearer token dict + :param token_handler: the token handler (BearerToken class) + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :return: The ID Token (a JWS signed JWT or JWE encrypted JWT) + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_jwt_bearer_token(self, token, scopes, request): + """Ensure the JWT Bearer token or OpenID Connect ID token are valids and authorized access to scopes. + + If using OpenID Connect this SHOULD call `oauthlib.oauth2.RequestValidator.get_id_token` + + If not using OpenID Connect this can `return None` to avoid 5xx rather 401/3 response. + + OpenID connect core 1.0 describe how to validate an id_token: + - http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + - http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation + - http://openid.net/specs/openid-connect-core-1_0.html#HybridIDTValidation + - http://openid.net/specs/openid-connect-core-1_0.html#HybridIDTValidation2 + + :param token: Unicode Bearer token + :param scopes: List of scopes (defined by you) + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is indirectly used by all core OpenID connect JWT token issuing grant types: + - Authorization Code Grant + - Implicit Grant + - Hybrid Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_id_token(self, token, scopes, request): + """Ensure the id token is valid and authorized access to scopes. + + OpenID connect core 1.0 describe how to validate an id_token: + - http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + - http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation + - http://openid.net/specs/openid-connect-core-1_0.html#HybridIDTValidation + - http://openid.net/specs/openid-connect-core-1_0.html#HybridIDTValidation2 + + :param token: Unicode Bearer token + :param scopes: List of scopes (defined by you) + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is indirectly used by all core OpenID connect JWT token issuing grant types: + - Authorization Code Grant + - Implicit Grant + - Hybrid Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_silent_authorization(self, request): + """Ensure the logged in user has authorized silent OpenID authorization. + + Silent OpenID authorization allows access tokens and id tokens to be + granted to clients without any user prompt or interaction. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_silent_login(self, request): + """Ensure session user has authorized silent OpenID login. + + If no user is logged in or has not authorized silent login, this + method should return False. + + If the user is logged in but associated with multiple accounts and + not selected which one to link to the token then this method should + raise an oauthlib.oauth2.AccountSelectionRequired error. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_user_match(self, id_token_hint, scopes, claims, request): + """Ensure client supplied user id hint matches session user. + + If the sub claim or id_token_hint is supplied then the session + user must match the given ID. + + :param id_token_hint: User identifier string. + :param scopes: List of OAuth 2 scopes and OpenID claims (strings). + :param claims: OpenID Connect claims dict. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_userinfo_claims(self, request): + """Return the UserInfo claims in JSON or Signed or Encrypted. + + The UserInfo Claims MUST be returned as the members of a JSON object + unless a signed or encrypted response was requested during Client + Registration. The Claims defined in Section 5.1 can be returned, as can + additional Claims not specified there. + + For privacy reasons, OpenID Providers MAY elect to not return values for + some requested Claims. + + If a Claim is not returned, that Claim Name SHOULD be omitted from the + JSON object representing the Claims; it SHOULD NOT be present with a + null or empty string value. + + The sub (subject) Claim MUST always be returned in the UserInfo + Response. + + Upon receipt of the UserInfo Request, the UserInfo Endpoint MUST return + the JSON Serialization of the UserInfo Response as in Section 13.3 in + the HTTP response body unless a different format was specified during + Registration [OpenID.Registration]. + + If the UserInfo Response is signed and/or encrypted, then the Claims are + returned in a JWT and the content-type MUST be application/jwt. The + response MAY be encrypted without also being signed. If both signing and + encryption are requested, the response MUST be signed then encrypted, + with the result being a Nested JWT, as defined in [JWT]. + + If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) + and aud (audience) as members. The iss value SHOULD be the OP's Issuer + Identifier URL. The aud value SHOULD be or include the RP's Client ID + value. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: Claims as a dict OR JWT/JWS/JWE as a string + + 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/contrib/python/oauthlib/oauthlib/openid/connect/core/tokens.py b/contrib/python/oauthlib/oauthlib/openid/connect/core/tokens.py new file mode 100644 index 0000000000..936ab52e38 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/openid/connect/core/tokens.py @@ -0,0 +1,48 @@ +""" +authlib.openid.connect.core.tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains methods for adding JWT tokens to requests. +""" +from oauthlib.oauth2.rfc6749.tokens import ( + TokenBase, get_token_from_header, random_token_generator, +) + + +class JWTToken(TokenBase): + __slots__ = ( + 'request_validator', 'token_generator', + 'refresh_token_generator', 'expires_in' + ) + + def __init__(self, request_validator=None, token_generator=None, + expires_in=None, refresh_token_generator=None): + self.request_validator = request_validator + self.token_generator = token_generator or random_token_generator + self.refresh_token_generator = ( + refresh_token_generator or self.token_generator + ) + self.expires_in = expires_in or 3600 + + def create_token(self, request, refresh_token=False): + """Create a JWT Token, using requestvalidator method.""" + + if callable(self.expires_in): + expires_in = self.expires_in(request) + else: + expires_in = self.expires_in + + request.expires_in = expires_in + + return self.request_validator.get_jwt_bearer_token(None, None, request) + + def validate_request(self, request): + token = get_token_from_header(request) + return self.request_validator.validate_jwt_bearer_token( + token, request.scopes, request) + + def estimate_type(self, request): + token = get_token_from_header(request) + if token and token.startswith('ey') and token.count('.') in (2, 4): + return 10 + return 0 diff --git a/contrib/python/oauthlib/oauthlib/signals.py b/contrib/python/oauthlib/oauthlib/signals.py new file mode 100644 index 0000000000..8fd347a5c8 --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/signals.py @@ -0,0 +1,40 @@ +""" + Implements signals based on blinker if available, otherwise + falls silently back to a noop. Shamelessly stolen from flask.signals: + https://github.com/mitsuhiko/flask/blob/master/flask/signals.py +""" +signals_available = False +try: + from blinker import Namespace + signals_available = True +except ImportError: # noqa + class Namespace: + def signal(self, name, doc=None): + return _FakeSignal(name, doc) + + class _FakeSignal: + """If blinker is unavailable, create a fake class with the same + interface that allows sending of signals but will fail with an + error on anything else. Instead of doing anything on send, it + will just ignore the arguments and do nothing instead. + """ + + def __init__(self, name, doc=None): + self.name = name + self.__doc__ = doc + def _fail(self, *args, **kwargs): + raise RuntimeError('signalling support is unavailable ' + 'because the blinker library is ' + 'not installed.') + send = lambda *a, **kw: None + connect = disconnect = has_receivers_for = receivers_for = \ + temporarily_connected_to = connected_to = _fail + del _fail + +# The namespace for code signals. If you are not oauthlib code, do +# not put signals in here. Create your own namespace instead. +_signals = Namespace() + + +# Core signals. +scope_changed = _signals.signal('scope-changed') diff --git a/contrib/python/oauthlib/oauthlib/uri_validate.py b/contrib/python/oauthlib/oauthlib/uri_validate.py new file mode 100644 index 0000000000..a6fe0fb23e --- /dev/null +++ b/contrib/python/oauthlib/oauthlib/uri_validate.py @@ -0,0 +1,190 @@ +""" +Regex for URIs + +These regex are directly derived from the collected ABNF in RFC3986 +(except for DIGIT, ALPHA and HEXDIG, defined by RFC2234). + +They should be processed with re.VERBOSE. + +Thanks Mark Nottingham for this code - https://gist.github.com/138549 +""" +import re + +# basics + +DIGIT = r"[\x30-\x39]" + +ALPHA = r"[\x41-\x5A\x61-\x7A]" + +HEXDIG = r"[\x30-\x39A-Fa-f]" + +# pct-encoded = "%" HEXDIG HEXDIG +pct_encoded = r" %% %(HEXDIG)s %(HEXDIG)s" % locals() + +# unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" +unreserved = r"(?: %(ALPHA)s | %(DIGIT)s | \- | \. | _ | ~ )" % locals() + +# gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" +gen_delims = r"(?: : | / | \? | \# | \[ | \] | @ )" + +# sub-delims = "!" / "$" / "&" / "'" / "(" / ")" +# / "*" / "+" / "," / ";" / "=" +sub_delims = r"""(?: ! | \$ | & | ' | \( | \) | + \* | \+ | , | ; | = )""" + +# pchar = unreserved / pct-encoded / sub-delims / ":" / "@" +pchar = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s | : | @ )" % locals( +) + +# reserved = gen-delims / sub-delims +reserved = r"(?: %(gen_delims)s | %(sub_delims)s )" % locals() + + +# scheme + +# scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) +scheme = r"%(ALPHA)s (?: %(ALPHA)s | %(DIGIT)s | \+ | \- | \. )*" % locals() + + +# authority + +# dec-octet = DIGIT ; 0-9 +# / %x31-39 DIGIT ; 10-99 +# / "1" 2DIGIT ; 100-199 +# / "2" %x30-34 DIGIT ; 200-249 +# / "25" %x30-35 ; 250-255 +dec_octet = r"""(?: %(DIGIT)s | + [\x31-\x39] %(DIGIT)s | + 1 %(DIGIT)s{2} | + 2 [\x30-\x34] %(DIGIT)s | + 25 [\x30-\x35] + ) +""" % locals() + +# IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet +IPv4address = r"%(dec_octet)s \. %(dec_octet)s \. %(dec_octet)s \. %(dec_octet)s" % locals( +) + +# IPv6address +IPv6address = r"([A-Fa-f0-9:]+[:$])[A-Fa-f0-9]{1,4}" + +# IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) +IPvFuture = r"v %(HEXDIG)s+ \. (?: %(unreserved)s | %(sub_delims)s | : )+" % locals() + +# IP-literal = "[" ( IPv6address / IPvFuture ) "]" +IP_literal = r"\[ (?: %(IPv6address)s | %(IPvFuture)s ) \]" % locals() + +# reg-name = *( unreserved / pct-encoded / sub-delims ) +reg_name = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s )*" % locals() + +# userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) +userinfo = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s | : )" % locals( +) + +# host = IP-literal / IPv4address / reg-name +host = r"(?: %(IP_literal)s | %(IPv4address)s | %(reg_name)s )" % locals() + +# port = *DIGIT +port = r"(?: %(DIGIT)s )*" % locals() + +# authority = [ userinfo "@" ] host [ ":" port ] +authority = r"(?: %(userinfo)s @)? %(host)s (?: : %(port)s)?" % locals() + +# Path + +# segment = *pchar +segment = r"%(pchar)s*" % locals() + +# segment-nz = 1*pchar +segment_nz = r"%(pchar)s+" % locals() + +# segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" ) +# ; non-zero-length segment without any colon ":" +segment_nz_nc = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s | @ )+" % locals() + +# path-abempty = *( "/" segment ) +path_abempty = r"(?: / %(segment)s )*" % locals() + +# path-absolute = "/" [ segment-nz *( "/" segment ) ] +path_absolute = r"/ (?: %(segment_nz)s (?: / %(segment)s )* )?" % locals() + +# path-noscheme = segment-nz-nc *( "/" segment ) +path_noscheme = r"%(segment_nz_nc)s (?: / %(segment)s )*" % locals() + +# path-rootless = segment-nz *( "/" segment ) +path_rootless = r"%(segment_nz)s (?: / %(segment)s )*" % locals() + +# path-empty = 0<pchar> +path_empty = r"" # FIXME + +# path = path-abempty ; begins with "/" or is empty +# / path-absolute ; begins with "/" but not "//" +# / path-noscheme ; begins with a non-colon segment +# / path-rootless ; begins with a segment +# / path-empty ; zero characters +path = r"""(?: %(path_abempty)s | + %(path_absolute)s | + %(path_noscheme)s | + %(path_rootless)s | + %(path_empty)s + ) +""" % locals() + +### Query and Fragment + +# query = *( pchar / "/" / "?" ) +query = r"(?: %(pchar)s | / | \? )*" % locals() + +# fragment = *( pchar / "/" / "?" ) +fragment = r"(?: %(pchar)s | / | \? )*" % locals() + +# URIs + +# hier-part = "//" authority path-abempty +# / path-absolute +# / path-rootless +# / path-empty +hier_part = r"""(?: (?: // %(authority)s %(path_abempty)s ) | + %(path_absolute)s | + %(path_rootless)s | + %(path_empty)s + ) +""" % locals() + +# relative-part = "//" authority path-abempty +# / path-absolute +# / path-noscheme +# / path-empty +relative_part = r"""(?: (?: // %(authority)s %(path_abempty)s ) | + %(path_absolute)s | + %(path_noscheme)s | + %(path_empty)s + ) +""" % locals() + +# relative-ref = relative-part [ "?" query ] [ "#" fragment ] +relative_ref = r"%(relative_part)s (?: \? %(query)s)? (?: \# %(fragment)s)?" % locals( +) + +# URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] +URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? (?: \# %(fragment)s )? )$" % locals( +) + +# URI-reference = URI / relative-ref +URI_reference = r"^(?: %(URI)s | %(relative_ref)s )$" % locals() + +# absolute-URI = scheme ":" hier-part [ "?" query ] +absolute_URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? )$" % locals( +) + + +def is_uri(uri): + return re.match(URI, uri, re.VERBOSE) + + +def is_uri_reference(uri): + return re.match(URI_reference, uri, re.VERBOSE) + + +def is_absolute_uri(uri): + return re.match(absolute_URI, uri, re.VERBOSE) diff --git a/contrib/python/oauthlib/tests/__init__.py b/contrib/python/oauthlib/tests/__init__.py new file mode 100644 index 0000000000..f33236b5ee --- /dev/null +++ b/contrib/python/oauthlib/tests/__init__.py @@ -0,0 +1,3 @@ +import oauthlib + +oauthlib.set_debug(True) diff --git a/contrib/python/oauthlib/tests/oauth1/__init__.py b/contrib/python/oauthlib/tests/oauth1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/__init__.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/__init__.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_access_token.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_access_token.py new file mode 100644 index 0000000000..57d8117531 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_access_token.py @@ -0,0 +1,91 @@ +from unittest.mock import ANY, MagicMock + +from oauthlib.oauth1 import RequestValidator +from oauthlib.oauth1.rfc5849 import Client +from oauthlib.oauth1.rfc5849.endpoints import AccessTokenEndpoint + +from tests.unittest import TestCase + + +class AccessTokenEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.check_client_key.return_value = True + self.validator.check_request_token.return_value = True + self.validator.check_verifier.return_value = True + self.validator.allowed_signature_methods = ['HMAC-SHA1'] + self.validator.get_client_secret.return_value = 'bar' + self.validator.get_request_token_secret.return_value = 'secret' + self.validator.get_realms.return_value = ['foo'] + self.validator.timestamp_lifetime = 600 + self.validator.validate_client_key.return_value = True + self.validator.validate_request_token.return_value = True + self.validator.validate_verifier.return_value = True + self.validator.validate_timestamp_and_nonce.return_value = True + self.validator.invalidate_request_token.return_value = True + self.validator.dummy_client = 'dummy' + self.validator.dummy_secret = 'dummy' + self.validator.dummy_request_token = 'dummy' + self.validator.save_access_token = MagicMock() + self.endpoint = AccessTokenEndpoint(self.validator) + self.client = Client('foo', + client_secret='bar', + resource_owner_key='token', + resource_owner_secret='secret', + verifier='verfier') + self.uri, self.headers, self.body = self.client.sign( + 'https://i.b/access_token') + + def test_check_request_token(self): + self.validator.check_request_token.return_value = False + h, b, s = self.endpoint.create_access_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_check_verifier(self): + self.validator.check_verifier.return_value = False + h, b, s = self.endpoint.create_access_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_validate_client_key(self): + self.validator.validate_client_key.return_value = False + h, b, s = self.endpoint.create_access_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 401) + + def test_validate_request_token(self): + self.validator.validate_request_token.return_value = False + h, b, s = self.endpoint.create_access_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 401) + + def test_validate_verifier(self): + self.validator.validate_verifier.return_value = False + h, b, s = self.endpoint.create_access_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 401) + + def test_validate_signature(self): + client = Client('foo', + resource_owner_key='token', + resource_owner_secret='secret', + verifier='verfier') + _, headers, _ = client.sign(self.uri + '/extra') + h, b, s = self.endpoint.create_access_token_response( + self.uri, headers=headers) + self.assertEqual(s, 401) + + def test_valid_request(self): + h, b, s = self.endpoint.create_access_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 200) + self.assertIn('oauth_token', b) + self.validator.validate_timestamp_and_nonce.assert_called_once_with( + self.client.client_key, ANY, ANY, ANY, + request_token=self.client.resource_owner_key) + self.validator.invalidate_request_token.assert_called_once_with( + self.client.client_key, self.client.resource_owner_key, ANY) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_authorization.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_authorization.py new file mode 100644 index 0000000000..a9b2fc0c9f --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_authorization.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock + +from oauthlib.oauth1 import RequestValidator +from oauthlib.oauth1.rfc5849 import errors +from oauthlib.oauth1.rfc5849.endpoints import AuthorizationEndpoint + +from tests.unittest import TestCase + + +class AuthorizationEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.verify_request_token.return_value = True + self.validator.verify_realms.return_value = True + self.validator.get_realms.return_value = ['test'] + self.validator.save_verifier = MagicMock() + self.endpoint = AuthorizationEndpoint(self.validator) + self.uri = 'https://i.b/authorize?oauth_token=foo' + + def test_get_realms_and_credentials(self): + realms, credentials = self.endpoint.get_realms_and_credentials(self.uri) + self.assertEqual(realms, ['test']) + + def test_verify_token(self): + self.validator.verify_request_token.return_value = False + self.assertRaises(errors.InvalidClientError, + self.endpoint.get_realms_and_credentials, self.uri) + self.assertRaises(errors.InvalidClientError, + self.endpoint.create_authorization_response, self.uri) + + def test_verify_realms(self): + self.validator.verify_realms.return_value = False + self.assertRaises(errors.InvalidRequestError, + self.endpoint.create_authorization_response, + self.uri, + realms=['bar']) + + def test_create_authorization_response(self): + self.validator.get_redirect_uri.return_value = 'https://c.b/cb' + h, b, s = self.endpoint.create_authorization_response(self.uri) + self.assertEqual(s, 302) + self.assertIn('Location', h) + location = h['Location'] + self.assertTrue(location.startswith('https://c.b/cb')) + self.assertIn('oauth_verifier', location) + + def test_create_authorization_response_oob(self): + self.validator.get_redirect_uri.return_value = 'oob' + h, b, s = self.endpoint.create_authorization_response(self.uri) + self.assertEqual(s, 200) + self.assertNotIn('Location', h) + self.assertIn('oauth_verifier', b) + self.assertIn('oauth_token', b) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_base.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_base.py new file mode 100644 index 0000000000..e87f359baa --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_base.py @@ -0,0 +1,406 @@ +from re import sub +from unittest.mock import MagicMock + +from oauthlib.common import CaseInsensitiveDict, safe_string_equals +from oauthlib.oauth1 import Client, RequestValidator +from oauthlib.oauth1.rfc5849 import ( + SIGNATURE_HMAC, SIGNATURE_PLAINTEXT, SIGNATURE_RSA, errors, +) +from oauthlib.oauth1.rfc5849.endpoints import ( + BaseEndpoint, RequestTokenEndpoint, +) + +from tests.unittest import TestCase + +URLENCODED = {"Content-Type": "application/x-www-form-urlencoded"} + + +class BaseEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(spec=RequestValidator) + self.validator.allowed_signature_methods = ['HMAC-SHA1'] + self.validator.timestamp_lifetime = 600 + self.endpoint = RequestTokenEndpoint(self.validator) + self.client = Client('foo', callback_uri='https://c.b/cb') + self.uri, self.headers, self.body = self.client.sign( + 'https://i.b/request_token') + + def test_ssl_enforcement(self): + uri, headers, _ = self.client.sign('http://i.b/request_token') + h, b, s = self.endpoint.create_request_token_response( + uri, headers=headers) + self.assertEqual(s, 400) + self.assertIn('insecure_transport_protocol', b) + + def test_missing_parameters(self): + h, b, s = self.endpoint.create_request_token_response(self.uri) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_signature_methods(self): + headers = {} + headers['Authorization'] = self.headers['Authorization'].replace( + 'HMAC', 'RSA') + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=headers) + self.assertEqual(s, 400) + self.assertIn('invalid_signature_method', b) + + def test_invalid_version(self): + headers = {} + headers['Authorization'] = self.headers['Authorization'].replace( + '1.0', '2.0') + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_expired_timestamp(self): + headers = {} + for pattern in ('12345678901', '4567890123', '123456789K'): + headers['Authorization'] = sub(r'timestamp="\d*k?"', + 'timestamp="%s"' % pattern, + self.headers['Authorization']) + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_client_key_check(self): + self.validator.check_client_key.return_value = False + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_noncecheck(self): + self.validator.check_nonce.return_value = False + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_enforce_ssl(self): + """Ensure SSL is enforced by default.""" + v = RequestValidator() + e = BaseEndpoint(v) + c = Client('foo') + u, h, b = c.sign('http://example.com') + r = e._create_request(u, 'GET', b, h) + self.assertRaises(errors.InsecureTransportError, + e._check_transport_security, r) + + def test_multiple_source_params(self): + """Check for duplicate params""" + v = RequestValidator() + e = BaseEndpoint(v) + self.assertRaises(errors.InvalidRequestError, e._create_request, + 'https://a.b/?oauth_signature_method=HMAC-SHA1', + 'GET', 'oauth_version=foo', URLENCODED) + headers = {'Authorization': 'OAuth oauth_signature="foo"'} + headers.update(URLENCODED) + self.assertRaises(errors.InvalidRequestError, e._create_request, + 'https://a.b/?oauth_signature_method=HMAC-SHA1', + 'GET', + 'oauth_version=foo', + headers) + headers = {'Authorization': 'OAuth oauth_signature_method="foo"'} + headers.update(URLENCODED) + self.assertRaises(errors.InvalidRequestError, e._create_request, + 'https://a.b/', + 'GET', + 'oauth_signature=foo', + headers) + + def test_duplicate_params(self): + """Ensure params are only supplied once""" + v = RequestValidator() + e = BaseEndpoint(v) + self.assertRaises(errors.InvalidRequestError, e._create_request, + 'https://a.b/?oauth_version=a&oauth_version=b', + 'GET', None, URLENCODED) + self.assertRaises(errors.InvalidRequestError, e._create_request, + 'https://a.b/', 'GET', 'oauth_version=a&oauth_version=b', + URLENCODED) + + def test_mandated_params(self): + """Ensure all mandatory params are present.""" + v = RequestValidator() + e = BaseEndpoint(v) + r = e._create_request('https://a.b/', 'GET', + 'oauth_signature=a&oauth_consumer_key=b&oauth_nonce', + URLENCODED) + self.assertRaises(errors.InvalidRequestError, + e._check_mandatory_parameters, r) + + def test_oauth_version(self): + """OAuth version must be 1.0 if present.""" + v = RequestValidator() + e = BaseEndpoint(v) + r = e._create_request('https://a.b/', 'GET', + ('oauth_signature=a&oauth_consumer_key=b&oauth_nonce=c&' + 'oauth_timestamp=a&oauth_signature_method=RSA-SHA1&' + 'oauth_version=2.0'), + URLENCODED) + self.assertRaises(errors.InvalidRequestError, + e._check_mandatory_parameters, r) + + def test_oauth_timestamp(self): + """Check for a valid UNIX timestamp.""" + v = RequestValidator() + e = BaseEndpoint(v) + + # Invalid timestamp length, must be 10 + r = e._create_request('https://a.b/', 'GET', + ('oauth_signature=a&oauth_consumer_key=b&oauth_nonce=c&' + 'oauth_version=1.0&oauth_signature_method=RSA-SHA1&' + 'oauth_timestamp=123456789'), + URLENCODED) + self.assertRaises(errors.InvalidRequestError, + e._check_mandatory_parameters, r) + + # Invalid timestamp age, must be younger than 10 minutes + r = e._create_request('https://a.b/', 'GET', + ('oauth_signature=a&oauth_consumer_key=b&oauth_nonce=c&' + 'oauth_version=1.0&oauth_signature_method=RSA-SHA1&' + 'oauth_timestamp=1234567890'), + URLENCODED) + self.assertRaises(errors.InvalidRequestError, + e._check_mandatory_parameters, r) + + # Timestamp must be an integer + r = e._create_request('https://a.b/', 'GET', + ('oauth_signature=a&oauth_consumer_key=b&oauth_nonce=c&' + 'oauth_version=1.0&oauth_signature_method=RSA-SHA1&' + 'oauth_timestamp=123456789a'), + URLENCODED) + self.assertRaises(errors.InvalidRequestError, + e._check_mandatory_parameters, r) + + def test_case_insensitive_headers(self): + """Ensure headers are case-insensitive""" + v = RequestValidator() + e = BaseEndpoint(v) + r = e._create_request('https://a.b', 'POST', + ('oauth_signature=a&oauth_consumer_key=b&oauth_nonce=c&' + 'oauth_version=1.0&oauth_signature_method=RSA-SHA1&' + 'oauth_timestamp=123456789a'), + URLENCODED) + self.assertIsInstance(r.headers, CaseInsensitiveDict) + + def test_signature_method_validation(self): + """Ensure valid signature method is used.""" + + body = ('oauth_signature=a&oauth_consumer_key=b&oauth_nonce=c&' + 'oauth_version=1.0&oauth_signature_method=%s&' + 'oauth_timestamp=1234567890') + + uri = 'https://example.com/' + + class HMACValidator(RequestValidator): + + @property + def allowed_signature_methods(self): + return (SIGNATURE_HMAC,) + + v = HMACValidator() + e = BaseEndpoint(v) + r = e._create_request(uri, 'GET', body % 'RSA-SHA1', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + r = e._create_request(uri, 'GET', body % 'PLAINTEXT', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + r = e._create_request(uri, 'GET', body % 'shibboleth', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + + class RSAValidator(RequestValidator): + + @property + def allowed_signature_methods(self): + return (SIGNATURE_RSA,) + + v = RSAValidator() + e = BaseEndpoint(v) + r = e._create_request(uri, 'GET', body % 'HMAC-SHA1', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + r = e._create_request(uri, 'GET', body % 'PLAINTEXT', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + r = e._create_request(uri, 'GET', body % 'shibboleth', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + + class PlainValidator(RequestValidator): + + @property + def allowed_signature_methods(self): + return (SIGNATURE_PLAINTEXT,) + + v = PlainValidator() + e = BaseEndpoint(v) + r = e._create_request(uri, 'GET', body % 'HMAC-SHA1', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + r = e._create_request(uri, 'GET', body % 'RSA-SHA1', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + r = e._create_request(uri, 'GET', body % 'shibboleth', URLENCODED) + self.assertRaises(errors.InvalidSignatureMethodError, + e._check_mandatory_parameters, r) + + +class ClientValidator(RequestValidator): + clients = ['foo'] + nonces = [('foo', 'once', '1234567891', 'fez')] + owners = {'foo': ['abcdefghijklmnopqrstuvxyz', 'fez']} + assigned_realms = {('foo', 'abcdefghijklmnopqrstuvxyz'): 'photos'} + verifiers = {('foo', 'fez'): 'shibboleth'} + + @property + def client_key_length(self): + return 1, 30 + + @property + def request_token_length(self): + return 1, 30 + + @property + def access_token_length(self): + return 1, 30 + + @property + def nonce_length(self): + return 2, 30 + + @property + def verifier_length(self): + return 2, 30 + + @property + def realms(self): + return ['photos'] + + @property + def timestamp_lifetime(self): + # Disabled check to allow hardcoded verification signatures + return 1000000000 + + @property + def dummy_client(self): + return 'dummy' + + @property + def dummy_request_token(self): + return 'dumbo' + + @property + def dummy_access_token(self): + return 'dumbo' + + def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, + request, request_token=None, access_token=None): + resource_owner_key = request_token if request_token else access_token + return not (client_key, nonce, timestamp, resource_owner_key) in self.nonces + + def validate_client_key(self, client_key): + return client_key in self.clients + + def validate_access_token(self, client_key, access_token, request): + return (self.owners.get(client_key) and + access_token in self.owners.get(client_key)) + + def validate_request_token(self, client_key, request_token, request): + return (self.owners.get(client_key) and + request_token in self.owners.get(client_key)) + + def validate_requested_realm(self, client_key, realm, request): + return True + + def validate_realm(self, client_key, access_token, request, uri=None, + required_realm=None): + return (client_key, access_token) in self.assigned_realms + + def validate_verifier(self, client_key, request_token, verifier, + request): + return ((client_key, request_token) in self.verifiers and + safe_string_equals(verifier, self.verifiers.get( + (client_key, request_token)))) + + def validate_redirect_uri(self, client_key, redirect_uri, request): + return redirect_uri.startswith('http://client.example.com/') + + def get_client_secret(self, client_key, request): + return 'super secret' + + def get_access_token_secret(self, client_key, access_token, request): + return 'even more secret' + + def get_request_token_secret(self, client_key, request_token, request): + return 'even more secret' + + def get_rsa_key(self, client_key, request): + return ("-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNA" + "DCBiQKBgQDVLQCATX8iK+aZuGVdkGb6uiar\nLi/jqFwL1dYj0JLIsdQc" + "KaMWtPC06K0+vI+RRZcjKc6sNB9/7kJcKN9Ekc9BUxyT\n/D09Cz47cmC" + "YsUoiW7G8NSqbE4wPiVpGkJRzFAxaCWwOSSQ+lpC9vwxnvVQfOoZ1\nnp" + "mWbCdA0iTxsMahwQIDAQAB\n-----END PUBLIC KEY-----") + + +class SignatureVerificationTest(TestCase): + + def setUp(self): + v = ClientValidator() + self.e = BaseEndpoint(v) + + self.uri = 'https://example.com/' + self.sig = ('oauth_signature=%s&' + 'oauth_timestamp=1234567890&' + 'oauth_nonce=abcdefghijklmnopqrstuvwxyz&' + 'oauth_version=1.0&' + 'oauth_signature_method=%s&' + 'oauth_token=abcdefghijklmnopqrstuvxyz&' + 'oauth_consumer_key=foo') + + def test_signature_too_short(self): + short_sig = ('oauth_signature=fmrXnTF4lO4o%2BD0%2FlZaJHP%2FXqEY&' + 'oauth_timestamp=1234567890&' + 'oauth_nonce=abcdefghijklmnopqrstuvwxyz&' + 'oauth_version=1.0&oauth_signature_method=HMAC-SHA1&' + 'oauth_token=abcdefghijklmnopqrstuvxyz&' + 'oauth_consumer_key=foo') + r = self.e._create_request(self.uri, 'GET', short_sig, URLENCODED) + self.assertFalse(self.e._check_signature(r)) + + plain = ('oauth_signature=correctlengthbutthewrongcontent1111&' + 'oauth_timestamp=1234567890&' + 'oauth_nonce=abcdefghijklmnopqrstuvwxyz&' + 'oauth_version=1.0&oauth_signature_method=PLAINTEXT&' + 'oauth_token=abcdefghijklmnopqrstuvxyz&' + 'oauth_consumer_key=foo') + r = self.e._create_request(self.uri, 'GET', plain, URLENCODED) + self.assertFalse(self.e._check_signature(r)) + + def test_hmac_signature(self): + hmac_sig = "fmrXnTF4lO4o%2BD0%2FlZaJHP%2FXqEY%3D" + sig = self.sig % (hmac_sig, "HMAC-SHA1") + r = self.e._create_request(self.uri, 'GET', sig, URLENCODED) + self.assertTrue(self.e._check_signature(r)) + + def test_rsa_signature(self): + rsa_sig = ("fxFvCx33oKlR9wDquJ%2FPsndFzJphyBa3RFPPIKi3flqK%2BJ7yIrMVbH" + "YTM%2FLHPc7NChWz4F4%2FzRA%2BDN1k08xgYGSBoWJUOW6VvOQ6fbYhMA" + "FkOGYbuGDbje487XMzsAcv6ZjqZHCROSCk5vofgLk2SN7RZ3OrgrFzf4in" + "xetClqA%3D") + sig = self.sig % (rsa_sig, "RSA-SHA1") + r = self.e._create_request(self.uri, 'GET', sig, URLENCODED) + self.assertTrue(self.e._check_signature(r)) + + def test_plaintext_signature(self): + plain_sig = "super%252520secret%26even%252520more%252520secret" + sig = self.sig % (plain_sig, "PLAINTEXT") + r = self.e._create_request(self.uri, 'GET', sig, URLENCODED) + self.assertTrue(self.e._check_signature(r)) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_request_token.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_request_token.py new file mode 100644 index 0000000000..879cad2f48 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_request_token.py @@ -0,0 +1,90 @@ +from unittest.mock import ANY, MagicMock + +from oauthlib.oauth1 import RequestValidator +from oauthlib.oauth1.rfc5849 import Client +from oauthlib.oauth1.rfc5849.endpoints import RequestTokenEndpoint + +from tests.unittest import TestCase + + +class RequestTokenEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.check_client_key.return_value = True + self.validator.allowed_signature_methods = ['HMAC-SHA1'] + self.validator.get_client_secret.return_value = 'bar' + self.validator.get_default_realms.return_value = ['foo'] + self.validator.timestamp_lifetime = 600 + self.validator.check_realms.return_value = True + self.validator.validate_client_key.return_value = True + self.validator.validate_requested_realms.return_value = True + self.validator.validate_redirect_uri.return_value = True + self.validator.validate_timestamp_and_nonce.return_value = True + self.validator.dummy_client = 'dummy' + self.validator.dummy_secret = 'dummy' + self.validator.save_request_token = MagicMock() + self.endpoint = RequestTokenEndpoint(self.validator) + self.client = Client('foo', client_secret='bar', realm='foo', + callback_uri='https://c.b/cb') + self.uri, self.headers, self.body = self.client.sign( + 'https://i.b/request_token') + + def test_check_redirect_uri(self): + client = Client('foo') + uri, headers, _ = client.sign(self.uri) + h, b, s = self.endpoint.create_request_token_response( + uri, headers=headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_check_realms(self): + self.validator.check_realms.return_value = False + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 400) + self.assertIn('invalid_request', b) + + def test_validate_client_key(self): + self.validator.validate_client_key.return_value = False + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 401) + + def test_validate_realms(self): + self.validator.validate_requested_realms.return_value = False + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 401) + + def test_validate_redirect_uri(self): + self.validator.validate_redirect_uri.return_value = False + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 401) + + def test_validate_signature(self): + client = Client('foo', callback_uri='https://c.b/cb') + _, headers, _ = client.sign(self.uri + '/extra') + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=headers) + self.assertEqual(s, 401) + + def test_valid_request(self): + h, b, s = self.endpoint.create_request_token_response( + self.uri, headers=self.headers) + self.assertEqual(s, 200) + self.assertIn('oauth_token', b) + self.validator.validate_timestamp_and_nonce.assert_called_once_with( + self.client.client_key, ANY, ANY, ANY, + request_token=self.client.resource_owner_key) + + def test_uri_provided_realm(self): + client = Client('foo', callback_uri='https://c.b/cb', + client_secret='bar') + uri = self.uri + '?realm=foo' + _, headers, _ = client.sign(uri) + h, b, s = self.endpoint.create_request_token_response( + uri, headers=headers) + self.assertEqual(s, 200) + self.assertIn('oauth_token', b) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_resource.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_resource.py new file mode 100644 index 0000000000..416216f737 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_resource.py @@ -0,0 +1,102 @@ +from unittest.mock import ANY, MagicMock + +from oauthlib.oauth1 import RequestValidator +from oauthlib.oauth1.rfc5849 import Client +from oauthlib.oauth1.rfc5849.endpoints import ResourceEndpoint + +from tests.unittest import TestCase + + +class ResourceEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.check_client_key.return_value = True + self.validator.check_access_token.return_value = True + self.validator.allowed_signature_methods = ['HMAC-SHA1'] + self.validator.get_client_secret.return_value = 'bar' + self.validator.get_access_token_secret.return_value = 'secret' + self.validator.timestamp_lifetime = 600 + self.validator.validate_client_key.return_value = True + self.validator.validate_access_token.return_value = True + self.validator.validate_timestamp_and_nonce.return_value = True + self.validator.validate_realms.return_value = True + self.validator.dummy_client = 'dummy' + self.validator.dummy_secret = 'dummy' + self.validator.dummy_access_token = 'dummy' + self.endpoint = ResourceEndpoint(self.validator) + self.client = Client('foo', + client_secret='bar', + resource_owner_key='token', + resource_owner_secret='secret') + self.uri, self.headers, self.body = self.client.sign( + 'https://i.b/protected_resource') + + def test_missing_parameters(self): + self.validator.check_access_token.return_value = False + v, r = self.endpoint.validate_protected_resource_request( + self.uri) + self.assertFalse(v) + + def test_check_access_token(self): + self.validator.check_access_token.return_value = False + v, r = self.endpoint.validate_protected_resource_request( + self.uri, headers=self.headers) + self.assertFalse(v) + + def test_validate_client_key(self): + self.validator.validate_client_key.return_value = False + v, r = self.endpoint.validate_protected_resource_request( + self.uri, headers=self.headers) + self.assertFalse(v) + # the validator log should have `False` values + self.assertFalse(r.validator_log['client']) + self.assertTrue(r.validator_log['realm']) + self.assertTrue(r.validator_log['resource_owner']) + self.assertTrue(r.validator_log['signature']) + + def test_validate_access_token(self): + self.validator.validate_access_token.return_value = False + v, r = self.endpoint.validate_protected_resource_request( + self.uri, headers=self.headers) + self.assertFalse(v) + # the validator log should have `False` values + self.assertTrue(r.validator_log['client']) + self.assertTrue(r.validator_log['realm']) + self.assertFalse(r.validator_log['resource_owner']) + self.assertTrue(r.validator_log['signature']) + + def test_validate_realms(self): + self.validator.validate_realms.return_value = False + v, r = self.endpoint.validate_protected_resource_request( + self.uri, headers=self.headers) + self.assertFalse(v) + # the validator log should have `False` values + self.assertTrue(r.validator_log['client']) + self.assertFalse(r.validator_log['realm']) + self.assertTrue(r.validator_log['resource_owner']) + self.assertTrue(r.validator_log['signature']) + + def test_validate_signature(self): + client = Client('foo', + resource_owner_key='token', + resource_owner_secret='secret') + _, headers, _ = client.sign(self.uri + '/extra') + v, r = self.endpoint.validate_protected_resource_request( + self.uri, headers=headers) + self.assertFalse(v) + # the validator log should have `False` values + self.assertTrue(r.validator_log['client']) + self.assertTrue(r.validator_log['realm']) + self.assertTrue(r.validator_log['resource_owner']) + self.assertFalse(r.validator_log['signature']) + + def test_valid_request(self): + v, r = self.endpoint.validate_protected_resource_request( + self.uri, headers=self.headers) + self.assertTrue(v) + self.validator.validate_timestamp_and_nonce.assert_called_once_with( + self.client.client_key, ANY, ANY, ANY, + access_token=self.client.resource_owner_key) + # everything in the validator_log should be `True` + self.assertTrue(all(r.validator_log.items())) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_signature_only.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_signature_only.py new file mode 100644 index 0000000000..16585bd580 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/endpoints/test_signature_only.py @@ -0,0 +1,50 @@ +from unittest.mock import ANY, MagicMock + +from oauthlib.oauth1 import RequestValidator +from oauthlib.oauth1.rfc5849 import Client +from oauthlib.oauth1.rfc5849.endpoints import SignatureOnlyEndpoint + +from tests.unittest import TestCase + + +class SignatureOnlyEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.check_client_key.return_value = True + self.validator.allowed_signature_methods = ['HMAC-SHA1'] + self.validator.get_client_secret.return_value = 'bar' + self.validator.timestamp_lifetime = 600 + self.validator.validate_client_key.return_value = True + self.validator.validate_timestamp_and_nonce.return_value = True + self.validator.dummy_client = 'dummy' + self.validator.dummy_secret = 'dummy' + self.endpoint = SignatureOnlyEndpoint(self.validator) + self.client = Client('foo', client_secret='bar') + self.uri, self.headers, self.body = self.client.sign( + 'https://i.b/protected_resource') + + def test_missing_parameters(self): + v, r = self.endpoint.validate_request( + self.uri) + self.assertFalse(v) + + def test_validate_client_key(self): + self.validator.validate_client_key.return_value = False + v, r = self.endpoint.validate_request( + self.uri, headers=self.headers) + self.assertFalse(v) + + def test_validate_signature(self): + client = Client('foo') + _, headers, _ = client.sign(self.uri + '/extra') + v, r = self.endpoint.validate_request( + self.uri, headers=headers) + self.assertFalse(v) + + def test_valid_request(self): + v, r = self.endpoint.validate_request( + self.uri, headers=self.headers) + self.assertTrue(v) + self.validator.validate_timestamp_and_nonce.assert_called_once_with( + self.client.client_key, ANY, ANY, ANY) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/test_client.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_client.py new file mode 100644 index 0000000000..f7c997f509 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_client.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +from oauthlib.common import Request +from oauthlib.oauth1 import ( + SIGNATURE_HMAC_SHA1, SIGNATURE_HMAC_SHA256, SIGNATURE_PLAINTEXT, + SIGNATURE_RSA, SIGNATURE_TYPE_BODY, SIGNATURE_TYPE_QUERY, +) +from oauthlib.oauth1.rfc5849 import Client + +from tests.unittest import TestCase + + +class ClientRealmTests(TestCase): + + def test_client_no_realm(self): + client = Client("client-key") + uri, header, body = client.sign("http://example-uri") + self.assertTrue( + header["Authorization"].startswith('OAuth oauth_nonce=')) + + def test_client_realm_sign_with_default_realm(self): + client = Client("client-key", realm="moo-realm") + self.assertEqual(client.realm, "moo-realm") + uri, header, body = client.sign("http://example-uri") + self.assertTrue( + header["Authorization"].startswith('OAuth realm="moo-realm",')) + + def test_client_realm_sign_with_additional_realm(self): + client = Client("client-key", realm="moo-realm") + uri, header, body = client.sign("http://example-uri", realm="baa-realm") + self.assertTrue( + header["Authorization"].startswith('OAuth realm="baa-realm",')) + # make sure sign() does not override the default realm + self.assertEqual(client.realm, "moo-realm") + + +class ClientConstructorTests(TestCase): + + def test_convert_to_unicode_resource_owner(self): + client = Client('client-key', + resource_owner_key=b'owner key') + self.assertNotIsInstance(client.resource_owner_key, bytes) + self.assertEqual(client.resource_owner_key, 'owner key') + + def test_give_explicit_timestamp(self): + client = Client('client-key', timestamp='1') + params = dict(client.get_oauth_params(Request('http://example.com'))) + self.assertEqual(params['oauth_timestamp'], '1') + + def test_give_explicit_nonce(self): + client = Client('client-key', nonce='1') + params = dict(client.get_oauth_params(Request('http://example.com'))) + self.assertEqual(params['oauth_nonce'], '1') + + def test_decoding(self): + client = Client('client_key', decoding='utf-8') + uri, headers, body = client.sign('http://a.b/path?query', + http_method='POST', body='a=b', + headers={'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertIsInstance(uri, bytes) + self.assertIsInstance(body, bytes) + for k, v in headers.items(): + self.assertIsInstance(k, bytes) + self.assertIsInstance(v, bytes) + + def test_hmac_sha1(self): + client = Client('client_key') + # instance is using the correct signer method + self.assertEqual(Client.SIGNATURE_METHODS[SIGNATURE_HMAC_SHA1], + client.SIGNATURE_METHODS[client.signature_method]) + + def test_hmac_sha256(self): + client = Client('client_key', signature_method=SIGNATURE_HMAC_SHA256) + # instance is using the correct signer method + self.assertEqual(Client.SIGNATURE_METHODS[SIGNATURE_HMAC_SHA256], + client.SIGNATURE_METHODS[client.signature_method]) + + def test_rsa(self): + client = Client('client_key', signature_method=SIGNATURE_RSA) + # instance is using the correct signer method + self.assertEqual(Client.SIGNATURE_METHODS[SIGNATURE_RSA], + client.SIGNATURE_METHODS[client.signature_method]) + # don't need an RSA key to instantiate + self.assertIsNone(client.rsa_key) + + +class SignatureMethodTest(TestCase): + + def test_hmac_sha1_method(self): + client = Client('client_key', timestamp='1234567890', nonce='abc') + u, h, b = client.sign('http://example.com') + correct = ('OAuth oauth_nonce="abc", oauth_timestamp="1234567890", ' + 'oauth_version="1.0", oauth_signature_method="HMAC-SHA1", ' + 'oauth_consumer_key="client_key", ' + 'oauth_signature="hH5BWYVqo7QI4EmPBUUe9owRUUQ%3D"') + self.assertEqual(h['Authorization'], correct) + + def test_hmac_sha256_method(self): + client = Client('client_key', signature_method=SIGNATURE_HMAC_SHA256, + timestamp='1234567890', nonce='abc') + u, h, b = client.sign('http://example.com') + correct = ('OAuth oauth_nonce="abc", oauth_timestamp="1234567890", ' + 'oauth_version="1.0", oauth_signature_method="HMAC-SHA256", ' + 'oauth_consumer_key="client_key", ' + 'oauth_signature="JzgJWBxX664OiMW3WE4MEjtYwOjI%2FpaUWHqtdHe68Es%3D"') + self.assertEqual(h['Authorization'], correct) + + def test_rsa_method(self): + private_key = ( + "-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQDk1/bxy" + "S8Q8jiheHeYYp/4rEKJopeQRRKKpZI4s5i+UPwVpupG\nAlwXWfzXw" + "SMaKPAoKJNdu7tqKRniqst5uoHXw98gj0x7zamu0Ck1LtQ4c7pFMVa" + "h\n5IYGhBi2E9ycNS329W27nJPWNCbESTu7snVlG8V8mfvGGg3xNjT" + "MO7IdrwIDAQAB\nAoGBAOQ2KuH8S5+OrsL4K+wfjoCi6MfxCUyqVU9" + "GxocdM1m30WyWRFMEz2nKJ8fR\np3vTD4w8yplTOhcoXdQZl0kRoaD" + "zrcYkm2VvJtQRrX7dKFT8dR8D/Tr7dNQLOXfC\nDY6xveQczE7qt7V" + "k7lp4FqmxBsaaEuokt78pOOjywZoInjZhAkEA9wz3zoZNT0/i\nrf6" + "qv2qTIeieUB035N3dyw6f1BGSWYaXSuerDCD/J1qZbAPKKhyHZbVaw" + "Ft3UMhe\n542UftBaxQJBAO0iJy1I8GQjGnS7B3yvyH3CcLYGy296+" + "XO/2xKp/d/ty1OIeovx\nC60pLNwuFNF3z9d2GVQAdoQ89hUkOtjZL" + "eMCQQD0JO6oPHUeUjYT+T7ImAv7UKVT\nSuy30sKjLzqoGw1kR+wv7" + "C5PeDRvscs4wa4CW9s6mjSrMDkDrmCLuJDtmf55AkEA\nkmaMg2PNr" + "jUR51F0zOEFycaaqXbGcFwe1/xx9zLmHzMDXd4bsnwt9kk+fe0hQzV" + "S\nJzatanQit3+feev1PN3QewJAWv4RZeavEUhKv+kLe95Yd0su7lT" + "LVduVgh4v5yLT\nGa6FHdjGPcfajt+nrpB1n8UQBEH9ZxniokR/IPv" + "dMlxqXA==\n-----END RSA PRIVATE KEY-----" + ) + client = Client('client_key', signature_method=SIGNATURE_RSA, + rsa_key=private_key, timestamp='1234567890', nonce='abc') + u, h, b = client.sign('http://example.com') + correct = ('OAuth oauth_nonce="abc", oauth_timestamp="1234567890", ' + 'oauth_version="1.0", oauth_signature_method="RSA-SHA1", ' + 'oauth_consumer_key="client_key", ' + 'oauth_signature="ktvzkUhtrIawBcq21DRJrAyysTc3E1Zq5GdGu8EzH' + 'OtbeaCmOBDLGHAcqlm92mj7xp5E1Z6i2vbExPimYAJL7FzkLnkRE5YEJR4' + 'rNtIgAf1OZbYsIUmmBO%2BCLuStuu5Lg3tAluwC7XkkgoXCBaRKT1mUXzP' + 'HJILzZ8iFOvS6w5E%3D"') + self.assertEqual(h['Authorization'], correct) + + def test_plaintext_method(self): + client = Client('client_key', + signature_method=SIGNATURE_PLAINTEXT, + timestamp='1234567890', + nonce='abc', + client_secret='foo', + resource_owner_secret='bar') + u, h, b = client.sign('http://example.com') + correct = ('OAuth oauth_nonce="abc", oauth_timestamp="1234567890", ' + 'oauth_version="1.0", oauth_signature_method="PLAINTEXT", ' + 'oauth_consumer_key="client_key", ' + 'oauth_signature="foo%26bar"') + self.assertEqual(h['Authorization'], correct) + + def test_invalid_method(self): + client = Client('client_key', signature_method='invalid') + self.assertRaises(ValueError, client.sign, 'http://example.com') + + def test_rsa_no_key(self): + client = Client('client_key', signature_method=SIGNATURE_RSA) + self.assertRaises(ValueError, client.sign, 'http://example.com') + + def test_register_method(self): + Client.register_signature_method('PIZZA', + lambda base_string, client: 'PIZZA') + + self.assertIn('PIZZA', Client.SIGNATURE_METHODS) + + client = Client('client_key', signature_method='PIZZA', + timestamp='1234567890', nonce='abc') + + u, h, b = client.sign('http://example.com') + + self.assertEqual(h['Authorization'], ( + 'OAuth oauth_nonce="abc", oauth_timestamp="1234567890", ' + 'oauth_version="1.0", oauth_signature_method="PIZZA", ' + 'oauth_consumer_key="client_key", ' + 'oauth_signature="PIZZA"' + )) + + +class SignatureTypeTest(TestCase): + + def test_params_in_body(self): + client = Client('client_key', signature_type=SIGNATURE_TYPE_BODY, + timestamp='1378988215', nonce='14205877133089081931378988215') + _, h, b = client.sign('http://i.b/path', http_method='POST', body='a=b', + headers={'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(h['Content-Type'], 'application/x-www-form-urlencoded') + correct = ('a=b&oauth_nonce=14205877133089081931378988215&' + 'oauth_timestamp=1378988215&' + 'oauth_version=1.0&' + 'oauth_signature_method=HMAC-SHA1&' + 'oauth_consumer_key=client_key&' + 'oauth_signature=2JAQomgbShqoscqKWBiYQZwWq94%3D') + self.assertEqual(b, correct) + + def test_params_in_query(self): + client = Client('client_key', signature_type=SIGNATURE_TYPE_QUERY, + timestamp='1378988215', nonce='14205877133089081931378988215') + u, _, _ = client.sign('http://i.b/path', http_method='POST') + correct = ('http://i.b/path?oauth_nonce=14205877133089081931378988215&' + 'oauth_timestamp=1378988215&' + 'oauth_version=1.0&' + 'oauth_signature_method=HMAC-SHA1&' + 'oauth_consumer_key=client_key&' + 'oauth_signature=08G5Snvw%2BgDAzBF%2BCmT5KqlrPKo%3D') + self.assertEqual(u, correct) + + def test_invalid_signature_type(self): + client = Client('client_key', signature_type='invalid') + self.assertRaises(ValueError, client.sign, 'http://i.b/path') + + +class SigningTest(TestCase): + + def test_case_insensitive_headers(self): + client = Client('client_key') + # Uppercase + _, h, _ = client.sign('http://i.b/path', http_method='POST', body='', + headers={'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(h['Content-Type'], 'application/x-www-form-urlencoded') + + # Lowercase + _, h, _ = client.sign('http://i.b/path', http_method='POST', body='', + headers={'content-type': 'application/x-www-form-urlencoded'}) + self.assertEqual(h['content-type'], 'application/x-www-form-urlencoded') + + # Capitalized + _, h, _ = client.sign('http://i.b/path', http_method='POST', body='', + headers={'Content-type': 'application/x-www-form-urlencoded'}) + self.assertEqual(h['Content-type'], 'application/x-www-form-urlencoded') + + # Random + _, h, _ = client.sign('http://i.b/path', http_method='POST', body='', + headers={'conTent-tYpe': 'application/x-www-form-urlencoded'}) + self.assertEqual(h['conTent-tYpe'], 'application/x-www-form-urlencoded') + + def test_sign_no_body(self): + client = Client('client_key', decoding='utf-8') + self.assertRaises(ValueError, client.sign, 'http://i.b/path', + http_method='POST', body=None, + headers={'Content-Type': 'application/x-www-form-urlencoded'}) + + def test_sign_body(self): + client = Client('client_key') + _, h, b = client.sign('http://i.b/path', http_method='POST', body='', + headers={'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(h['Content-Type'], 'application/x-www-form-urlencoded') + + def test_sign_get_with_body(self): + client = Client('client_key') + for method in ('GET', 'HEAD'): + self.assertRaises(ValueError, client.sign, 'http://a.b/path?query', + http_method=method, body='a=b', + headers={ + 'Content-Type': 'application/x-www-form-urlencoded' + }) + + def test_sign_unicode(self): + client = Client('client_key', nonce='abc', timestamp='abc') + _, h, b = client.sign('http://i.b/path', http_method='POST', + body='status=%E5%95%A6%E5%95%A6', + headers={'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(b, 'status=%E5%95%A6%E5%95%A6') + self.assertIn('oauth_signature="yrtSqp88m%2Fc5UDaucI8BXK4oEtk%3D"', h['Authorization']) + _, h, b = client.sign('http://i.b/path', http_method='POST', + body='status=%C3%A6%C3%A5%C3%B8', + headers={'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(b, 'status=%C3%A6%C3%A5%C3%B8') + self.assertIn('oauth_signature="oG5t3Eg%2FXO5FfQgUUlTtUeeZzvk%3D"', h['Authorization']) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/test_parameters.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_parameters.py new file mode 100644 index 0000000000..92b95c1167 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_parameters.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +from oauthlib.common import urlencode +from oauthlib.oauth1.rfc5849.parameters import ( + _append_params, prepare_form_encoded_body, prepare_headers, + prepare_request_uri_query, +) + +from tests.unittest import TestCase + + +class ParameterTests(TestCase): + auth_only_params = [ + ('oauth_consumer_key', "9djdj82h48djs9d2"), + ('oauth_token', "kkk9d7dh3k39sjv7"), + ('oauth_signature_method', "HMAC-SHA1"), + ('oauth_timestamp', "137131201"), + ('oauth_nonce', "7d8f3e4a"), + ('oauth_signature', "bYT5CMsGcbgUdFHObYMEfcx6bsw=") + ] + auth_and_data = list(auth_only_params) + auth_and_data.append(('data_param_foo', 'foo')) + auth_and_data.append(('data_param_1', '1')) + realm = 'testrealm' + norealm_authorization_header = ' '.join(( + 'OAuth', + 'oauth_consumer_key="9djdj82h48djs9d2",', + 'oauth_token="kkk9d7dh3k39sjv7",', + 'oauth_signature_method="HMAC-SHA1",', + 'oauth_timestamp="137131201",', + 'oauth_nonce="7d8f3e4a",', + 'oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D"', + )) + withrealm_authorization_header = ' '.join(( + 'OAuth', + 'realm="testrealm",', + 'oauth_consumer_key="9djdj82h48djs9d2",', + 'oauth_token="kkk9d7dh3k39sjv7",', + 'oauth_signature_method="HMAC-SHA1",', + 'oauth_timestamp="137131201",', + 'oauth_nonce="7d8f3e4a",', + 'oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D"', + )) + + def test_append_params(self): + unordered_1 = [ + ('oauth_foo', 'foo'), + ('lala', 123), + ('oauth_baz', 'baz'), + ('oauth_bar', 'bar'), ] + unordered_2 = [ + ('teehee', 456), + ('oauth_quux', 'quux'), ] + expected = [ + ('teehee', 456), + ('lala', 123), + ('oauth_quux', 'quux'), + ('oauth_foo', 'foo'), + ('oauth_baz', 'baz'), + ('oauth_bar', 'bar'), ] + self.assertEqual(_append_params(unordered_1, unordered_2), expected) + + def test_prepare_headers(self): + self.assertEqual( + prepare_headers(self.auth_only_params, {}), + {'Authorization': self.norealm_authorization_header}) + self.assertEqual( + prepare_headers(self.auth_only_params, {}, realm=self.realm), + {'Authorization': self.withrealm_authorization_header}) + + def test_prepare_headers_ignore_data(self): + self.assertEqual( + prepare_headers(self.auth_and_data, {}), + {'Authorization': self.norealm_authorization_header}) + self.assertEqual( + prepare_headers(self.auth_and_data, {}, realm=self.realm), + {'Authorization': self.withrealm_authorization_header}) + + def test_prepare_form_encoded_body(self): + existing_body = '' + form_encoded_body = 'data_param_foo=foo&data_param_1=1&oauth_consumer_key=9djdj82h48djs9d2&oauth_token=kkk9d7dh3k39sjv7&oauth_signature_method=HMAC-SHA1&oauth_timestamp=137131201&oauth_nonce=7d8f3e4a&oauth_signature=bYT5CMsGcbgUdFHObYMEfcx6bsw%3D' + self.assertEqual( + urlencode(prepare_form_encoded_body(self.auth_and_data, existing_body)), + form_encoded_body) + + def test_prepare_request_uri_query(self): + url = 'http://notarealdomain.com/foo/bar/baz?some=args&go=here' + request_uri_query = 'http://notarealdomain.com/foo/bar/baz?some=args&go=here&data_param_foo=foo&data_param_1=1&oauth_consumer_key=9djdj82h48djs9d2&oauth_token=kkk9d7dh3k39sjv7&oauth_signature_method=HMAC-SHA1&oauth_timestamp=137131201&oauth_nonce=7d8f3e4a&oauth_signature=bYT5CMsGcbgUdFHObYMEfcx6bsw%3D' + self.assertEqual( + prepare_request_uri_query(self.auth_and_data, url), + request_uri_query) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/test_request_validator.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_request_validator.py new file mode 100644 index 0000000000..8d34415040 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_request_validator.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +from oauthlib.oauth1 import RequestValidator + +from tests.unittest import TestCase + + +class RequestValidatorTests(TestCase): + + def test_not_implemented(self): + v = RequestValidator() + self.assertRaises(NotImplementedError, v.get_client_secret, None, None) + self.assertRaises(NotImplementedError, v.get_request_token_secret, + None, None, None) + self.assertRaises(NotImplementedError, v.get_access_token_secret, + None, None, None) + self.assertRaises(NotImplementedError, lambda: v.dummy_client) + self.assertRaises(NotImplementedError, lambda: v.dummy_request_token) + self.assertRaises(NotImplementedError, lambda: v.dummy_access_token) + self.assertRaises(NotImplementedError, v.get_rsa_key, None, None) + self.assertRaises(NotImplementedError, v.get_default_realms, None, None) + self.assertRaises(NotImplementedError, v.get_realms, None, None) + self.assertRaises(NotImplementedError, v.get_redirect_uri, None, None) + self.assertRaises(NotImplementedError, v.validate_client_key, None, None) + self.assertRaises(NotImplementedError, v.validate_access_token, + None, None, None) + self.assertRaises(NotImplementedError, v.validate_request_token, + None, None, None) + self.assertRaises(NotImplementedError, v.verify_request_token, + None, None) + self.assertRaises(NotImplementedError, v.verify_realms, + None, None, None) + self.assertRaises(NotImplementedError, v.validate_timestamp_and_nonce, + None, None, None, None) + self.assertRaises(NotImplementedError, v.validate_redirect_uri, + None, None, None) + self.assertRaises(NotImplementedError, v.validate_realms, + None, None, None, None, None) + self.assertRaises(NotImplementedError, v.validate_requested_realms, + None, None, None) + self.assertRaises(NotImplementedError, v.validate_verifier, + None, None, None, None) + self.assertRaises(NotImplementedError, v.save_access_token, None, None) + self.assertRaises(NotImplementedError, v.save_request_token, None, None) + self.assertRaises(NotImplementedError, v.save_verifier, + None, None, None) + + def test_check_length(self): + v = RequestValidator() + + for method in (v.check_client_key, v.check_request_token, + v.check_access_token, v.check_nonce, v.check_verifier): + for not_valid in ('tooshort', 'invalid?characters!', + 'thisclientkeyisalittlebittoolong'): + self.assertFalse(method(not_valid)) + for valid in ('itsjustaboutlongenough',): + self.assertTrue(method(valid)) + + def test_check_realms(self): + v = RequestValidator() + self.assertFalse(v.check_realms(['foo'])) + + class FooRealmValidator(RequestValidator): + @property + def realms(self): + return ['foo'] + + v = FooRealmValidator() + self.assertTrue(v.check_realms(['foo'])) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/test_signatures.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_signatures.py new file mode 100644 index 0000000000..2d4735eafd --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_signatures.py @@ -0,0 +1,896 @@ +# -*- coding: utf-8 -*- +from oauthlib.oauth1.rfc5849.signature import ( + base_string_uri, collect_parameters, normalize_parameters, + sign_hmac_sha1_with_client, sign_hmac_sha256_with_client, + sign_hmac_sha512_with_client, sign_plaintext_with_client, + sign_rsa_sha1_with_client, sign_rsa_sha256_with_client, + sign_rsa_sha512_with_client, signature_base_string, verify_hmac_sha1, + verify_hmac_sha256, verify_hmac_sha512, verify_plaintext, verify_rsa_sha1, + verify_rsa_sha256, verify_rsa_sha512, +) + +from tests.unittest import TestCase + +# ################################################################ + +class MockRequest: + """ + Mock of a request used by the verify_* functions. + """ + + def __init__(self, + method: str, + uri_str: str, + params: list, + signature: str): + """ + The params is a list of (name, value) tuples. It is not a dictionary, + because there can be multiple parameters with the same name. + """ + self.uri = uri_str + self.http_method = method + self.params = params + self.signature = signature + + +# ################################################################ + +class MockClient: + """ + Mock of client credentials used by the sign_*_with_client functions. + + For HMAC, set the client_secret and resource_owner_secret. + + For RSA, set the rsa_key to either a PEM formatted PKCS #1 public key or + PEM formatted PKCS #1 private key. + """ + def __init__(self, + client_secret: str = None, + resource_owner_secret: str = None, + rsa_key: str = None): + self.client_secret = client_secret + self.resource_owner_secret = resource_owner_secret + self.rsa_key = rsa_key # used for private or public key: a poor design! + + +# ################################################################ + +class SignatureTests(TestCase): + """ + Unit tests for the oauthlib/oauth1/rfc5849/signature.py module. + + The tests in this class are organised into sections, to test the + functions relating to: + + - Signature base string calculation + - HMAC-based signature methods + - RSA-based signature methods + - PLAINTEXT signature method + + Each section is separated by a comment beginning with "====". + + Those comments have been formatted to remain visible when the code is + collapsed using PyCharm's code folding feature. That is, those section + heading comments do not have any other comment lines around it, so they + don't get collapsed when the contents of the class is collapsed. While + there is a "Sequential comments" option in the code folding configuration, + by default they are folded. + + They all use some/all of the example test vector, defined in the first + section below. + """ + + # ==== Example test vector ======================================= + + eg_signature_base_string =\ + 'POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q' \ + '%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_' \ + 'key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m' \ + 'ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk' \ + '9d7dh3k39sjv7' + + # The _signature base string_ above is copied from the end of + # RFC 5849 section 3.4.1.1. + # + # It corresponds to the three values below. + # + # The _normalized parameters_ below is copied from the end of + # RFC 5849 section 3.4.1.3.2. + + eg_http_method = 'POST' + + eg_base_string_uri = 'http://example.com/request' + + eg_normalized_parameters =\ + 'a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj' \ + 'dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1' \ + '&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7' + + # The above _normalized parameters_ corresponds to the parameters below. + # + # The parameters below is copied from the table at the end of + # RFC 5849 section 3.4.1.3.1. + + eg_params = [ + ('b5', '=%3D'), + ('a3', 'a'), + ('c@', ''), + ('a2', 'r b'), + ('oauth_consumer_key', '9djdj82h48djs9d2'), + ('oauth_token', 'kkk9d7dh3k39sjv7'), + ('oauth_signature_method', 'HMAC-SHA1'), + ('oauth_timestamp', '137131201'), + ('oauth_nonce', '7d8f3e4a'), + ('c2', ''), + ('a3', '2 q'), + ] + + # The above parameters correspond to parameters from the three values below. + # + # These come from RFC 5849 section 3.4.1.3.1. + + eg_uri_query = 'b5=%3D%253D&a3=a&c%40=&a2=r%20b' + + eg_body = 'c2&a3=2+q' + + eg_authorization_header =\ + 'OAuth realm="Example", oauth_consumer_key="9djdj82h48djs9d2",' \ + ' oauth_token="kkk9d7dh3k39sjv7", oauth_signature_method="HMAC-SHA1",' \ + ' oauth_timestamp="137131201", oauth_nonce="7d8f3e4a",' \ + ' oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D"' + + # ==== Signature base string calculating function tests ========== + + def test_signature_base_string(self): + """ + Test the ``signature_base_string`` function. + """ + + # Example from RFC 5849 + + self.assertEqual( + self.eg_signature_base_string, + signature_base_string( + self.eg_http_method, + self.eg_base_string_uri, + self.eg_normalized_parameters)) + + # Test method is always uppercase in the signature base string + + for test_method in ['POST', 'Post', 'pOST', 'poST', 'posT', 'post']: + self.assertEqual( + self.eg_signature_base_string, + signature_base_string( + test_method, + self.eg_base_string_uri, + self.eg_normalized_parameters)) + + def test_base_string_uri(self): + """ + Test the ``base_string_uri`` function. + """ + + # ---------------- + # Examples from the OAuth 1.0a specification: RFC 5849. + + # First example from RFC 5849 section 3.4.1.2. + # + # GET /r%20v/X?id=123 HTTP/1.1 + # Host: EXAMPLE.COM:80 + # + # Note: there is a space between "r" and "v" + + self.assertEqual( + 'http://example.com/r%20v/X', + base_string_uri('http://EXAMPLE.COM:80/r v/X?id=123')) + + # Second example from RFC 5849 section 3.4.1.2. + # + # GET /?q=1 HTTP/1.1 + # Host: www.example.net:8080 + + self.assertEqual( + 'https://www.example.net:8080/', + base_string_uri('https://www.example.net:8080/?q=1')) + + # ---------------- + # Scheme: will always be in lowercase + + for uri in [ + 'foobar://www.example.com', + 'FOOBAR://www.example.com', + 'Foobar://www.example.com', + 'FooBar://www.example.com', + 'fOObAR://www.example.com', + ]: + self.assertEqual('foobar://www.example.com/', base_string_uri(uri)) + + # ---------------- + # Host: will always be in lowercase + + for uri in [ + 'http://www.example.com', + 'http://WWW.EXAMPLE.COM', + 'http://www.EXAMPLE.com', + 'http://wWW.eXAMPLE.cOM', + ]: + self.assertEqual('http://www.example.com/', base_string_uri(uri)) + + # base_string_uri has an optional host parameter that can be used to + # override the URI's netloc (or used as the host if there is no netloc) + # The "netloc" refers to the "hostname[:port]" part of the URI. + + self.assertEqual( + 'http://actual.example.com/', + base_string_uri('http://IGNORE.example.com', 'ACTUAL.example.com')) + + self.assertEqual( + 'http://override.example.com/path', + base_string_uri('http:///path', 'OVERRIDE.example.com')) + + # ---------------- + # Host: valid host allows for IPv4 and IPv6 + + self.assertEqual( + 'https://192.168.0.1/', + base_string_uri('https://192.168.0.1') + ) + self.assertEqual( + 'https://192.168.0.1:13000/', + base_string_uri('https://192.168.0.1:13000') + ) + self.assertEqual( + 'https://[123:db8:fd00:1000::5]:13000/', + base_string_uri('https://[123:db8:fd00:1000::5]:13000') + ) + self.assertEqual( + 'https://[123:db8:fd00:1000::5]/', + base_string_uri('https://[123:db8:fd00:1000::5]') + ) + + # ---------------- + # Port: default ports always excluded; non-default ports always included + + self.assertEqual( + "http://www.example.com/", + base_string_uri("http://www.example.com:80/")) # default port + + self.assertEqual( + "https://www.example.com/", + base_string_uri("https://www.example.com:443/")) # default port + + self.assertEqual( + "https://www.example.com:999/", + base_string_uri("https://www.example.com:999/")) # non-default port + + self.assertEqual( + "http://www.example.com:443/", + base_string_uri("HTTP://www.example.com:443/")) # non-default port + + self.assertEqual( + "https://www.example.com:80/", + base_string_uri("HTTPS://www.example.com:80/")) # non-default port + + self.assertEqual( + "http://www.example.com/", + base_string_uri("http://www.example.com:/")) # colon but no number + + # ---------------- + # Paths + + self.assertEqual( + 'http://www.example.com/', + base_string_uri('http://www.example.com')) # no slash + + self.assertEqual( + 'http://www.example.com/', + base_string_uri('http://www.example.com/')) # with slash + + self.assertEqual( + 'http://www.example.com:8080/', + base_string_uri('http://www.example.com:8080')) # no slash + + self.assertEqual( + 'http://www.example.com:8080/', + base_string_uri('http://www.example.com:8080/')) # with slash + + self.assertEqual( + 'http://www.example.com/foo/bar', + base_string_uri('http://www.example.com/foo/bar')) # no slash + self.assertEqual( + 'http://www.example.com/foo/bar/', + base_string_uri('http://www.example.com/foo/bar/')) # with slash + + # ---------------- + # Query parameters & fragment IDs do not appear in the base string URI + + self.assertEqual( + 'https://www.example.com/path', + base_string_uri('https://www.example.com/path?foo=bar')) + + self.assertEqual( + 'https://www.example.com/path', + base_string_uri('https://www.example.com/path#fragment')) + + # ---------------- + # Percent encoding + # + # RFC 5849 does not specify what characters are percent encoded, but in + # one of its examples it shows spaces being percent encoded. + # So it is assumed that spaces must be encoded, but we don't know what + # other characters are encoded or not. + + self.assertEqual( + 'https://www.example.com/hello%20world', + base_string_uri('https://www.example.com/hello world')) + + self.assertEqual( + 'https://www.hello%20world.com/', + base_string_uri('https://www.hello world.com/')) + + # ---------------- + # Errors detected + + # base_string_uri expects a string + self.assertRaises(ValueError, base_string_uri, None) + self.assertRaises(ValueError, base_string_uri, 42) + self.assertRaises(ValueError, base_string_uri, b'http://example.com') + + # Missing scheme is an error + self.assertRaises(ValueError, base_string_uri, '') + self.assertRaises(ValueError, base_string_uri, ' ') # single space + self.assertRaises(ValueError, base_string_uri, 'http') + self.assertRaises(ValueError, base_string_uri, 'example.com') + + # Missing host is an error + self.assertRaises(ValueError, base_string_uri, 'http:') + self.assertRaises(ValueError, base_string_uri, 'http://') + self.assertRaises(ValueError, base_string_uri, 'http://:8080') + + # Port is not a valid TCP/IP port number + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:0') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:-1') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:65536') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:3.14') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:BAD') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:NaN') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com: ') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:42:42') + + def test_collect_parameters(self): + """ + Test the ``collect_parameters`` function. + """ + + # ---------------- + # Examples from the OAuth 1.0a specification: RFC 5849. + + params = collect_parameters( + self.eg_uri_query, + self.eg_body, + {'Authorization': self.eg_authorization_header}) + + # Check params contains the same pairs as control_params, ignoring order + self.assertEqual(sorted(self.eg_params), sorted(params)) + + # ---------------- + # Examples with no parameters + + self.assertEqual([], collect_parameters('', '', {})) + + self.assertEqual([], collect_parameters(None, None, None)) + + self.assertEqual([], collect_parameters()) + + self.assertEqual([], collect_parameters(headers={'foo': 'bar'})) + + # ---------------- + # Test effect of exclude_oauth_signature" + + no_sig = collect_parameters( + headers={'authorization': self.eg_authorization_header}) + with_sig = collect_parameters( + headers={'authorization': self.eg_authorization_header}, + exclude_oauth_signature=False) + + self.assertEqual(sorted(no_sig + [('oauth_signature', + 'djosJKDKJSD8743243/jdk33klY=')]), + sorted(with_sig)) + + # ---------------- + # Test effect of "with_realm" as well as header name case insensitivity + + no_realm = collect_parameters( + headers={'authorization': self.eg_authorization_header}, + with_realm=False) + with_realm = collect_parameters( + headers={'AUTHORIZATION': self.eg_authorization_header}, + with_realm=True) + + self.assertEqual(sorted(no_realm + [('realm', 'Example')]), + sorted(with_realm)) + + def test_normalize_parameters(self): + """ + Test the ``normalize_parameters`` function. + """ + + # headers = {'Authorization': self.authorization_header} + # parameters = collect_parameters( + # uri_query=self.uri_query, body=self.body, headers=headers) + # normalized = normalize_parameters(parameters) + # + # # Unicode everywhere and always + # self.assertIsInstance(normalized, str) + # + # # Lets see if things are in order + # # check to see that querystring keys come in alphanumeric order: + # querystring_keys = ['a2', 'a3', 'b5', 'oauth_consumer_key', + # 'oauth_nonce', 'oauth_signature_method', + # 'oauth_timestamp', 'oauth_token'] + # index = -1 # start at -1 because the 'a2' key starts at index 0 + # for key in querystring_keys: + # self.assertGreater(normalized.index(key), index) + # index = normalized.index(key) + + # ---------------- + # Example from the OAuth 1.0a specification: RFC 5849. + # Params from end of section 3.4.1.3.1. and the expected + # normalized parameters from the end of section 3.4.1.3.2. + + self.assertEqual(self.eg_normalized_parameters, + normalize_parameters(self.eg_params)) + + # ==== HMAC-based signature method tests ========================= + + hmac_client = MockClient( + client_secret='ECrDNoq1VYzzzzzzzzzyAK7TwZNtPnkqatqZZZZ', + resource_owner_secret='just-a-string asdasd') + + # The following expected signatures were calculated by putting the value of + # the eg_signature_base_string in a file ("base-str.txt") and running: + # + # echo -n `cat base-str.txt` | openssl dgst -hmac KEY -sha1 -binary| base64 + # + # Where the KEY is the concatenation of the client_secret, an ampersand and + # the resource_owner_secret. But those values need to be encoded properly, + # so the spaces in the resource_owner_secret must be represented as '%20'. + # + # Note: the "echo -n" is needed to remove the last newline character, which + # most text editors will add. + + expected_signature_hmac_sha1 = \ + 'wsdNmjGB7lvis0UJuPAmjvX/PXw=' + + expected_signature_hmac_sha256 = \ + 'wdfdHUKXHbOnOGZP8WFAWMSAmWzN3EVBWWgXGlC/Eo4=' + + expected_signature_hmac_sha512 = \ + 'u/vlyZFDxOWOZ9UUXwRBJHvq8/T4jCA74ocRmn2ECnjUBTAeJiZIRU8hDTjS88Tz' \ + '1fGONffMpdZxUkUTW3k1kg==' + + def test_sign_hmac_sha1_with_client(self): + """ + Test sign and verify with HMAC-SHA1. + """ + self.assertEqual( + self.expected_signature_hmac_sha1, + sign_hmac_sha1_with_client(self.eg_signature_base_string, + self.hmac_client)) + self.assertTrue(verify_hmac_sha1( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_hmac_sha1), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + def test_sign_hmac_sha256_with_client(self): + """ + Test sign and verify with HMAC-SHA256. + """ + self.assertEqual( + self.expected_signature_hmac_sha256, + sign_hmac_sha256_with_client(self.eg_signature_base_string, + self.hmac_client)) + self.assertTrue(verify_hmac_sha256( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_hmac_sha256), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + def test_sign_hmac_sha512_with_client(self): + """ + Test sign and verify with HMAC-SHA512. + """ + self.assertEqual( + self.expected_signature_hmac_sha512, + sign_hmac_sha512_with_client(self.eg_signature_base_string, + self.hmac_client)) + self.assertTrue(verify_hmac_sha512( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_hmac_sha512), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + def test_hmac_false_positives(self): + """ + Test verify_hmac-* functions will correctly detect invalid signatures. + """ + + _ros = self.hmac_client.resource_owner_secret + + for functions in [ + (sign_hmac_sha1_with_client, verify_hmac_sha1), + (sign_hmac_sha256_with_client, verify_hmac_sha256), + (sign_hmac_sha512_with_client, verify_hmac_sha512), + ]: + signing_function = functions[0] + verify_function = functions[1] + + good_signature = \ + signing_function( + self.eg_signature_base_string, + self.hmac_client) + + bad_signature_on_different_value = \ + signing_function( + 'not the signature base string', + self.hmac_client) + + bad_signature_produced_by_different_client_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(client_secret='wrong-secret', + resource_owner_secret=_ros)) + bad_signature_produced_by_different_resource_owner_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(client_secret=self.hmac_client.client_secret, + resource_owner_secret='wrong-secret')) + + bad_signature_produced_with_no_resource_owner_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(client_secret=self.hmac_client.client_secret)) + bad_signature_produced_with_no_client_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(resource_owner_secret=_ros)) + + self.assertTrue(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + good_signature), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + for bad_signature in [ + '', + 'ZG9uJ3QgdHJ1c3QgbWUK', # random base64 encoded value + 'altérer', # value with a non-ASCII character in it + bad_signature_on_different_value, + bad_signature_produced_by_different_client_secret, + bad_signature_produced_by_different_resource_owner_secret, + bad_signature_produced_with_no_resource_owner_secret, + bad_signature_produced_with_no_client_secret, + ]: + self.assertFalse(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + bad_signature), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + # ==== RSA-based signature methods tests ========================= + + rsa_private_client = MockClient(rsa_key=''' +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDk1/bxyS8Q8jiheHeYYp/4rEKJopeQRRKKpZI4s5i+UPwVpupG +AlwXWfzXwSMaKPAoKJNdu7tqKRniqst5uoHXw98gj0x7zamu0Ck1LtQ4c7pFMVah +5IYGhBi2E9ycNS329W27nJPWNCbESTu7snVlG8V8mfvGGg3xNjTMO7IdrwIDAQAB +AoGBAOQ2KuH8S5+OrsL4K+wfjoCi6MfxCUyqVU9GxocdM1m30WyWRFMEz2nKJ8fR +p3vTD4w8yplTOhcoXdQZl0kRoaDzrcYkm2VvJtQRrX7dKFT8dR8D/Tr7dNQLOXfC +DY6xveQczE7qt7Vk7lp4FqmxBsaaEuokt78pOOjywZoInjZhAkEA9wz3zoZNT0/i +rf6qv2qTIeieUB035N3dyw6f1BGSWYaXSuerDCD/J1qZbAPKKhyHZbVawFt3UMhe +542UftBaxQJBAO0iJy1I8GQjGnS7B3yvyH3CcLYGy296+XO/2xKp/d/ty1OIeovx +C60pLNwuFNF3z9d2GVQAdoQ89hUkOtjZLeMCQQD0JO6oPHUeUjYT+T7ImAv7UKVT +Suy30sKjLzqoGw1kR+wv7C5PeDRvscs4wa4CW9s6mjSrMDkDrmCLuJDtmf55AkEA +kmaMg2PNrjUR51F0zOEFycaaqXbGcFwe1/xx9zLmHzMDXd4bsnwt9kk+fe0hQzVS +JzatanQit3+feev1PN3QewJAWv4RZeavEUhKv+kLe95Yd0su7lTLVduVgh4v5yLT +Ga6FHdjGPcfajt+nrpB1n8UQBEH9ZxniokR/IPvdMlxqXA== +-----END RSA PRIVATE KEY----- +''') + + rsa_public_client = MockClient(rsa_key=''' +-----BEGIN RSA PUBLIC KEY----- +MIGJAoGBAOTX9vHJLxDyOKF4d5hin/isQomil5BFEoqlkjizmL5Q/BWm6kYCXBdZ +/NfBIxoo8Cgok127u2opGeKqy3m6gdfD3yCPTHvNqa7QKTUu1DhzukUxVqHkhgaE +GLYT3Jw1Lfb1bbuck9Y0JsRJO7uydWUbxXyZ+8YaDfE2NMw7sh2vAgMBAAE= +-----END RSA PUBLIC KEY----- +''') + + # The above private key was generated using: + # $ openssl genrsa -out example.pvt 1024 + # $ chmod 600 example.pvt + # Public key was extract from it using: + # $ ssh-keygen -e -m pem -f example.pvt + # PEM encoding requires the key to be concatenated with linebreaks. + + # The following expected signatures were calculated by putting the private + # key in a file (test.pvt) and the value of sig_base_str_rsa in another file + # ("base-str.txt") and running: + # + # echo -n `cat base-str.txt` | openssl dgst -sha1 -sign test.pvt| base64 + # + # Note: the "echo -n" is needed to remove the last newline character, which + # most text editors will add. + + expected_signature_rsa_sha1 = \ + 'mFY2KOEnlYWsTvUA+5kxuBIcvBYXu+ljw9ttVJQxKduMueGSVPCB1tK1PlqVLK738' \ + 'HK0t19ecBJfb6rMxUwrriw+MlBO+jpojkZIWccw1J4cAb4qu4M81DbpUAq4j/1w/Q' \ + 'yTR4TWCODlEfN7Zfgy8+pf+TjiXfIwRC1jEWbuL1E=' + + expected_signature_rsa_sha256 = \ + 'jqKl6m0WS69tiVJV8ZQ6aQEfJqISoZkiPBXRv6Al2+iFSaDpfeXjYm+Hbx6m1azR' \ + 'drZ/35PM3cvuid3LwW/siAkzb0xQcGnTyAPH8YcGWzmnKGY7LsB7fkqThchNxvRK' \ + '/N7s9M1WMnfZZ+1dQbbwtTs1TG1+iexUcV7r3M7Heec=' + + expected_signature_rsa_sha512 = \ + 'jL1CnjlsNd25qoZVHZ2oJft47IRYTjpF5CvCUjL3LY0NTnbEeVhE4amWXUFBe9GL' \ + 'DWdUh/79ZWNOrCirBFIP26cHLApjYdt4ZG7EVK0/GubS2v8wT1QPRsog8zyiMZkm' \ + 'g4JXdWCGXG8YRvRJTg+QKhXuXwS6TcMNakrgzgFIVhA=' + + def test_sign_rsa_sha1_with_client(self): + """ + Test sign and verify with RSA-SHA1. + """ + self.assertEqual( + self.expected_signature_rsa_sha1, + sign_rsa_sha1_with_client(self.eg_signature_base_string, + self.rsa_private_client)) + self.assertTrue(verify_rsa_sha1( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha1), + self.rsa_public_client.rsa_key)) + + def test_sign_rsa_sha256_with_client(self): + """ + Test sign and verify with RSA-SHA256. + """ + self.assertEqual( + self.expected_signature_rsa_sha256, + sign_rsa_sha256_with_client(self.eg_signature_base_string, + self.rsa_private_client)) + self.assertTrue(verify_rsa_sha256( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha256), + self.rsa_public_client.rsa_key)) + + def test_sign_rsa_sha512_with_client(self): + """ + Test sign and verify with RSA-SHA512. + """ + self.assertEqual( + self.expected_signature_rsa_sha512, + sign_rsa_sha512_with_client(self.eg_signature_base_string, + self.rsa_private_client)) + self.assertTrue(verify_rsa_sha512( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha512), + self.rsa_public_client.rsa_key)) + + def test_rsa_false_positives(self): + """ + Test verify_rsa-* functions will correctly detect invalid signatures. + """ + + another_client = MockClient(rsa_key=''' +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDZcD/1OZNJJ6Y3QZM16Z+O7fkD9kTIQuT2BfpAOUvDfxzYhVC9 +TNmSDHCQhr+ClutyolBk5jTE1/FXFUuHoPsTrkI7KQFXPP834D4gnSY9jrAiUJHe +DVF6wXNuS7H4Ueh16YPjUxgLLRh/nn/JSEj98gsw+7DP01OWMfWS99S7eQIDAQAB +AoGBALsQZRXVyK7BG7CiC8HwEcNnXDpaXmZjlpNKJTenk1THQMvONd4GBZAuf5D3 +PD9fE4R1u/ByVKecmBaxTV+L0TRQfD8K/nbQe0SKRQIkLI2ymLJKC/eyw5iTKT0E ++BS6wYpVd+mfcqgvpHOYpUmz9X8k/eOa7uslFmvt+sDb5ZcBAkEA+++SRqqUxFEG +s/ZWAKw9p5YgkeVUOYVUwyAeZ97heySrjVzg1nZ6v6kv7iOPi9KOEpaIGPW7x1K/ +uQuSt4YEqQJBANzyNqZTTPpv7b/R8ABFy0YMwPVNt3b1GOU1Xxl6iuhH2WcHuueo +UB13JHoZCMZ7hsEqieEz6uteUjdRzRPKclECQFNhVK4iop3emzNQYeJTHwyp+RmQ +JrHq2MTDioyiDUouNsDQbnFMQQ/RtNVB265Q/0hTnbN1ELLFRkK9+87VghECQQC9 +hacLFPk6+TffCp3sHfI3rEj4Iin1iFhKhHWGzW7JwJfjoOXaQK44GDLZ6Q918g+t +MmgDHR2tt8KeYTSgfU+BAkBcaVF91EQ7VXhvyABNYjeYP7lU7orOgdWMa/zbLXSU +4vLsK1WOmwPY9zsXpPkilqszqcru4gzlG462cSbEdAW9 +-----END RSA PRIVATE KEY----- +''') + + for functions in [ + (sign_rsa_sha1_with_client, verify_rsa_sha1), + (sign_rsa_sha256_with_client, verify_rsa_sha256), + (sign_rsa_sha512_with_client, verify_rsa_sha512), + ]: + signing_function = functions[0] + verify_function = functions[1] + + good_signature = \ + signing_function(self.eg_signature_base_string, + self.rsa_private_client) + + bad_signature_on_different_value = \ + signing_function('wrong value signed', self.rsa_private_client) + + bad_signature_produced_by_different_private_key = \ + signing_function(self.eg_signature_base_string, another_client) + + self.assertTrue(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + good_signature), + self.rsa_public_client.rsa_key)) + + for bad_signature in [ + '', + 'ZG9uJ3QgdHJ1c3QgbWUK', # random base64 encoded value + 'altérer', # value with a non-ASCII character in it + bad_signature_on_different_value, + bad_signature_produced_by_different_private_key, + ]: + self.assertFalse(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + bad_signature), + self.rsa_public_client.rsa_key)) + + def test_rsa_bad_keys(self): + """ + Testing RSA sign and verify with bad key values produces errors. + + This test is useful for coverage tests, since it runs the code branches + that deal with error situations. + """ + + # Signing needs a private key + + for bad_value in [None, '', 'foobar']: + self.assertRaises(ValueError, + sign_rsa_sha1_with_client, + self.eg_signature_base_string, + MockClient(rsa_key=bad_value)) + + self.assertRaises(AttributeError, + sign_rsa_sha1_with_client, + self.eg_signature_base_string, + self.rsa_public_client) # public key doesn't sign + + # Verify needs a public key + + for bad_value in [None, '', 'foobar', self.rsa_private_client.rsa_key]: + self.assertRaises(TypeError, + verify_rsa_sha1, + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha1), + MockClient(rsa_key=bad_value)) + + # For completeness, this text could repeat the above for RSA-SHA256 and + # RSA-SHA512 signing and verification functions. + + def test_rsa_jwt_algorithm_cache(self): + # Tests cache of RSAAlgorithm objects is implemented correctly. + + # This is difficult to test, since the cache is internal. + # + # Running this test with coverage will show the cache-hit branch of code + # being executed by two signing operations with the same hash algorithm. + + self.test_sign_rsa_sha1_with_client() # creates cache entry + self.test_sign_rsa_sha1_with_client() # reuses cache entry + + # Some possible bugs will be detected if multiple signing operations + # with different hash algorithms produce the wrong results (e.g. if the + # cache incorrectly returned the previously used algorithm, instead + # of the one that is needed). + + self.test_sign_rsa_sha256_with_client() + self.test_sign_rsa_sha256_with_client() + self.test_sign_rsa_sha1_with_client() + self.test_sign_rsa_sha256_with_client() + self.test_sign_rsa_sha512_with_client() + + # ==== PLAINTEXT signature method tests ========================== + + plaintext_client = hmac_client # for convenience, use the same HMAC secrets + + expected_signature_plaintext = ( + 'ECrDNoq1VYzzzzzzzzzyAK7TwZNtPnkqatqZZZZ' + '&' + 'just-a-string%20%20%20%20asdasd') + + def test_sign_plaintext_with_client(self): + # With PLAINTEXT, the "signature" is always the same: regardless of the + # contents of the request. It is the concatenation of the encoded + # client_secret, an ampersand, and the encoded resource_owner_secret. + # + # That is why the spaces in the resource owner secret are "%20". + + self.assertEqual(self.expected_signature_plaintext, + sign_plaintext_with_client(None, # request is ignored + self.plaintext_client)) + self.assertTrue(verify_plaintext( + MockRequest('PUT', + 'http://example.com/some-other-path', + [('description', 'request is ignored in PLAINTEXT')], + self.expected_signature_plaintext), + self.plaintext_client.client_secret, + self.plaintext_client.resource_owner_secret)) + + def test_plaintext_false_positives(self): + """ + Test verify_plaintext function will correctly detect invalid signatures. + """ + + _ros = self.plaintext_client.resource_owner_secret + + good_signature = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + self.plaintext_client) + + bad_signature_produced_by_different_client_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(client_secret='wrong-secret', + resource_owner_secret=_ros)) + bad_signature_produced_by_different_resource_owner_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(client_secret=self.plaintext_client.client_secret, + resource_owner_secret='wrong-secret')) + + bad_signature_produced_with_no_resource_owner_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(client_secret=self.plaintext_client.client_secret)) + bad_signature_produced_with_no_client_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(resource_owner_secret=_ros)) + + self.assertTrue(verify_plaintext( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + good_signature), + self.plaintext_client.client_secret, + self.plaintext_client.resource_owner_secret)) + + for bad_signature in [ + '', + 'ZG9uJ3QgdHJ1c3QgbWUK', # random base64 encoded value + 'altérer', # value with a non-ASCII character in it + bad_signature_produced_by_different_client_secret, + bad_signature_produced_by_different_resource_owner_secret, + bad_signature_produced_with_no_resource_owner_secret, + bad_signature_produced_with_no_client_secret, + ]: + self.assertFalse(verify_plaintext( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + bad_signature), + self.plaintext_client.client_secret, + self.plaintext_client.resource_owner_secret)) diff --git a/contrib/python/oauthlib/tests/oauth1/rfc5849/test_utils.py b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_utils.py new file mode 100644 index 0000000000..013c71a910 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth1/rfc5849/test_utils.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +from oauthlib.oauth1.rfc5849.utils import * + +from tests.unittest import TestCase + + +class UtilsTests(TestCase): + + sample_params_list = [ + ("notoauth", "shouldnotbehere"), + ("oauth_consumer_key", "9djdj82h48djs9d2"), + ("oauth_token", "kkk9d7dh3k39sjv7"), + ("notoautheither", "shouldnotbehere") + ] + + sample_params_dict = { + "notoauth": "shouldnotbehere", + "oauth_consumer_key": "9djdj82h48djs9d2", + "oauth_token": "kkk9d7dh3k39sjv7", + "notoautheither": "shouldnotbehere" + } + + sample_params_unicode_list = [ + ("notoauth", "shouldnotbehere"), + ("oauth_consumer_key", "9djdj82h48djs9d2"), + ("oauth_token", "kkk9d7dh3k39sjv7"), + ("notoautheither", "shouldnotbehere") + ] + + sample_params_unicode_dict = { + "notoauth": "shouldnotbehere", + "oauth_consumer_key": "9djdj82h48djs9d2", + "oauth_token": "kkk9d7dh3k39sjv7", + "notoautheither": "shouldnotbehere" + } + + authorization_header = """OAuth realm="Example", + oauth_consumer_key="9djdj82h48djs9d2", + oauth_token="kkk9d7dh3k39sjv7", + oauth_signature_method="HMAC-SHA1", + oauth_timestamp="137131201", + oauth_nonce="7d8f3e4a", + oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D" """.strip() + bad_authorization_headers = ( + "OAuth", + "OAuth oauth_nonce=", + "Negotiate b2F1dGhsaWI=", + "OA", + ) + + def test_filter_params(self): + + # The following is an isolated test function used to test the filter_params decorator. + @filter_params + def special_test_function(params, realm=None): + """ I am a special test function """ + return 'OAuth ' + ','.join(['='.join([k, v]) for k, v in params]) + + # check that the docstring got through + self.assertEqual(special_test_function.__doc__, " I am a special test function ") + + # Check that the decorator filtering works as per design. + # Any param that does not start with 'oauth' + # should not be present in the filtered params + filtered_params = special_test_function(self.sample_params_list) + self.assertNotIn("notoauth", filtered_params) + self.assertIn("oauth_consumer_key", filtered_params) + self.assertIn("oauth_token", filtered_params) + self.assertNotIn("notoautheither", filtered_params) + + def test_filter_oauth_params(self): + + # try with list + # try with list + # try with list + self.assertEqual(len(self.sample_params_list), 4) + + # Any param that does not start with 'oauth' + # should not be present in the filtered params + filtered_params = filter_oauth_params(self.sample_params_list) + self.assertEqual(len(filtered_params), 2) + + self.assertTrue(filtered_params[0][0].startswith('oauth')) + self.assertTrue(filtered_params[1][0].startswith('oauth')) + + # try with dict + # try with dict + # try with dict + self.assertEqual(len(self.sample_params_dict), 4) + + # Any param that does not start with 'oauth' + # should not be present in the filtered params + filtered_params = filter_oauth_params(self.sample_params_dict) + self.assertEqual(len(filtered_params), 2) + + self.assertTrue(filtered_params[0][0].startswith('oauth')) + self.assertTrue(filtered_params[1][0].startswith('oauth')) + + def test_escape(self): + self.assertRaises(ValueError, escape, b"I am a string type. Not a unicode type.") + self.assertEqual(escape("I am a unicode type."), "I%20am%20a%20unicode%20type.") + self.assertIsInstance(escape("I am a unicode type."), str) + + def test_unescape(self): + self.assertRaises(ValueError, unescape, b"I am a string type. Not a unicode type.") + self.assertEqual(unescape("I%20am%20a%20unicode%20type."), 'I am a unicode type.') + self.assertIsInstance(unescape("I%20am%20a%20unicode%20type."), str) + + def test_parse_authorization_header(self): + # make us some headers + authorization_headers = parse_authorization_header(self.authorization_header) + + # is it a list? + self.assertIsInstance(authorization_headers, list) + + # are the internal items tuples? + for header in authorization_headers: + self.assertIsInstance(header, tuple) + + # are the internal components of each tuple unicode? + for k, v in authorization_headers: + self.assertIsInstance(k, str) + self.assertIsInstance(v, str) + + # let's check the parsed headers created + correct_headers = [ + ("oauth_nonce", "7d8f3e4a"), + ("oauth_timestamp", "137131201"), + ("oauth_consumer_key", "9djdj82h48djs9d2"), + ('oauth_signature', 'djosJKDKJSD8743243%2Fjdk33klY%3D'), + ('oauth_signature_method', 'HMAC-SHA1'), + ('oauth_token', 'kkk9d7dh3k39sjv7'), + ('realm', 'Example')] + self.assertEqual(sorted(authorization_headers), sorted(correct_headers)) + + # Check against malformed headers. + for header in self.bad_authorization_headers: + self.assertRaises(ValueError, parse_authorization_header, header) diff --git a/contrib/python/oauthlib/tests/oauth2/__init__.py b/contrib/python/oauthlib/tests/oauth2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/__init__.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/__init__.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_backend_application.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_backend_application.py new file mode 100644 index 0000000000..c1489ac7c6 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_backend_application.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +import os +from unittest.mock import patch + +from oauthlib import signals +from oauthlib.oauth2 import BackendApplicationClient + +from tests.unittest import TestCase + + +@patch('time.time', new=lambda: 1000) +class BackendApplicationClientTest(TestCase): + + client_id = "someclientid" + client_secret = 'someclientsecret' + scope = ["/profile"] + kwargs = { + "some": "providers", + "require": "extra arguments" + } + + body = "not=empty" + + body_up = "not=empty&grant_type=client_credentials" + body_kwargs = body_up + "&some=providers&require=extra+arguments" + + token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_in":3600,' + ' "scope":"/profile",' + ' "example_parameter":"example_value"}') + token = { + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "expires_at": 4600, + "scope": ["/profile"], + "example_parameter": "example_value" + } + + def test_request_body(self): + client = BackendApplicationClient(self.client_id) + + # Basic, no extra arguments + body = client.prepare_request_body(body=self.body) + self.assertFormBodyEqual(body, self.body_up) + + rclient = BackendApplicationClient(self.client_id) + body = rclient.prepare_request_body(body=self.body) + self.assertFormBodyEqual(body, self.body_up) + + # With extra parameters + body = client.prepare_request_body(body=self.body, **self.kwargs) + self.assertFormBodyEqual(body, self.body_kwargs) + + def test_parse_token_response(self): + client = BackendApplicationClient(self.client_id) + + # Parse code and state + response = client.parse_request_body_response(self.token_json, scope=self.scope) + self.assertEqual(response, self.token) + self.assertEqual(client.access_token, response.get("access_token")) + self.assertEqual(client.refresh_token, response.get("refresh_token")) + self.assertEqual(client.token_type, response.get("token_type")) + + # Mismatching state + self.assertRaises(Warning, client.parse_request_body_response, self.token_json, scope="invalid") + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '3' + token = client.parse_request_body_response(self.token_json, scope="invalid") + self.assertTrue(token.scope_changed) + + scope_changes_recorded = [] + def record_scope_change(sender, message, old, new): + scope_changes_recorded.append((message, old, new)) + + signals.scope_changed.connect(record_scope_change) + try: + client.parse_request_body_response(self.token_json, scope="invalid") + self.assertEqual(len(scope_changes_recorded), 1) + message, old, new = scope_changes_recorded[0] + self.assertEqual(message, 'Scope has changed from "invalid" to "/profile".') + self.assertEqual(old, ['invalid']) + self.assertEqual(new, ['/profile']) + finally: + signals.scope_changed.disconnect(record_scope_change) + del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_base.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_base.py new file mode 100644 index 0000000000..70a22834c3 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_base.py @@ -0,0 +1,355 @@ +# -*- coding: utf-8 -*- +import datetime + +from oauthlib import common +from oauthlib.oauth2 import Client, InsecureTransportError, TokenExpiredError +from oauthlib.oauth2.rfc6749 import utils +from oauthlib.oauth2.rfc6749.clients import AUTH_HEADER, BODY, URI_QUERY + +from tests.unittest import TestCase + + +class ClientTest(TestCase): + + client_id = "someclientid" + uri = "https://example.com/path?query=world" + body = "not=empty" + headers = {} + access_token = "token" + mac_key = "secret" + + bearer_query = uri + "&access_token=" + access_token + bearer_header = { + "Authorization": "Bearer " + access_token + } + bearer_body = body + "&access_token=" + access_token + + mac_00_header = { + "Authorization": 'MAC id="' + access_token + '", nonce="0:abc123",' + + ' bodyhash="Yqyso8r3hR5Nm1ZFv+6AvNHrxjE=",' + + ' mac="0X6aACoBY0G6xgGZVJ1IeE8dF9k="' + } + mac_01_header = { + "Authorization": 'MAC id="' + access_token + '", ts="123456789",' + + ' nonce="abc123", mac="Xuk+9oqaaKyhitkgh1CD0xrI6+s="' + } + + def test_add_bearer_token(self): + """Test a number of bearer token placements""" + + # Invalid token type + client = Client(self.client_id, token_type="invalid") + self.assertRaises(ValueError, client.add_token, self.uri) + + # Case-insensitive token type + client = Client(self.client_id, access_token=self.access_token, token_type="bEAreR") + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers) + self.assertURLEqual(uri, self.uri) + self.assertFormBodyEqual(body, self.body) + self.assertEqual(headers, self.bearer_header) + + # Non-HTTPS + insecure_uri = 'http://example.com/path?query=world' + client = Client(self.client_id, access_token=self.access_token, token_type="Bearer") + self.assertRaises(InsecureTransportError, client.add_token, insecure_uri, + body=self.body, + headers=self.headers) + + # Missing access token + client = Client(self.client_id) + self.assertRaises(ValueError, client.add_token, self.uri) + + # Expired token + expired = 523549800 + expired_token = { + 'expires_at': expired, + } + client = Client(self.client_id, token=expired_token, access_token=self.access_token, token_type="Bearer") + self.assertRaises(TokenExpiredError, client.add_token, self.uri, + body=self.body, headers=self.headers) + + # The default token placement, bearer in auth header + client = Client(self.client_id, access_token=self.access_token) + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers) + self.assertURLEqual(uri, self.uri) + self.assertFormBodyEqual(body, self.body) + self.assertEqual(headers, self.bearer_header) + + # Setting default placements of tokens + client = Client(self.client_id, access_token=self.access_token, + default_token_placement=AUTH_HEADER) + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers) + self.assertURLEqual(uri, self.uri) + self.assertFormBodyEqual(body, self.body) + self.assertEqual(headers, self.bearer_header) + + client = Client(self.client_id, access_token=self.access_token, + default_token_placement=URI_QUERY) + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers) + self.assertURLEqual(uri, self.bearer_query) + self.assertFormBodyEqual(body, self.body) + self.assertEqual(headers, self.headers) + + client = Client(self.client_id, access_token=self.access_token, + default_token_placement=BODY) + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers) + self.assertURLEqual(uri, self.uri) + self.assertFormBodyEqual(body, self.bearer_body) + self.assertEqual(headers, self.headers) + + # Asking for specific placement in the add_token method + client = Client(self.client_id, access_token=self.access_token) + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers, token_placement=AUTH_HEADER) + self.assertURLEqual(uri, self.uri) + self.assertFormBodyEqual(body, self.body) + self.assertEqual(headers, self.bearer_header) + + client = Client(self.client_id, access_token=self.access_token) + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers, token_placement=URI_QUERY) + self.assertURLEqual(uri, self.bearer_query) + self.assertFormBodyEqual(body, self.body) + self.assertEqual(headers, self.headers) + + client = Client(self.client_id, access_token=self.access_token) + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers, token_placement=BODY) + self.assertURLEqual(uri, self.uri) + self.assertFormBodyEqual(body, self.bearer_body) + self.assertEqual(headers, self.headers) + + # Invalid token placement + client = Client(self.client_id, access_token=self.access_token) + self.assertRaises(ValueError, client.add_token, self.uri, body=self.body, + headers=self.headers, token_placement="invalid") + + client = Client(self.client_id, access_token=self.access_token, + default_token_placement="invalid") + self.assertRaises(ValueError, client.add_token, self.uri, body=self.body, + headers=self.headers) + + def test_add_mac_token(self): + # Missing access token + client = Client(self.client_id, token_type="MAC") + self.assertRaises(ValueError, client.add_token, self.uri) + + # Invalid hash algorithm + client = Client(self.client_id, token_type="MAC", + access_token=self.access_token, mac_key=self.mac_key, + mac_algorithm="hmac-sha-2") + self.assertRaises(ValueError, client.add_token, self.uri) + + orig_generate_timestamp = common.generate_timestamp + orig_generate_nonce = common.generate_nonce + orig_generate_age = utils.generate_age + self.addCleanup(setattr, common, 'generage_timestamp', orig_generate_timestamp) + self.addCleanup(setattr, common, 'generage_nonce', orig_generate_nonce) + self.addCleanup(setattr, utils, 'generate_age', orig_generate_age) + common.generate_timestamp = lambda: '123456789' + common.generate_nonce = lambda: 'abc123' + utils.generate_age = lambda *args: 0 + + # Add the Authorization header (draft 00) + client = Client(self.client_id, token_type="MAC", + access_token=self.access_token, mac_key=self.mac_key, + mac_algorithm="hmac-sha-1") + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers, issue_time=datetime.datetime.now()) + self.assertEqual(uri, self.uri) + self.assertEqual(body, self.body) + self.assertEqual(headers, self.mac_00_header) + # Non-HTTPS + insecure_uri = 'http://example.com/path?query=world' + self.assertRaises(InsecureTransportError, client.add_token, insecure_uri, + body=self.body, + headers=self.headers, + issue_time=datetime.datetime.now()) + # Expired Token + expired = 523549800 + expired_token = { + 'expires_at': expired, + } + client = Client(self.client_id, token=expired_token, token_type="MAC", + access_token=self.access_token, mac_key=self.mac_key, + mac_algorithm="hmac-sha-1") + self.assertRaises(TokenExpiredError, client.add_token, self.uri, + body=self.body, + headers=self.headers, + issue_time=datetime.datetime.now()) + + # Add the Authorization header (draft 01) + client = Client(self.client_id, token_type="MAC", + access_token=self.access_token, mac_key=self.mac_key, + mac_algorithm="hmac-sha-1") + uri, headers, body = client.add_token(self.uri, body=self.body, + headers=self.headers, draft=1) + self.assertEqual(uri, self.uri) + self.assertEqual(body, self.body) + self.assertEqual(headers, self.mac_01_header) + # Non-HTTPS + insecure_uri = 'http://example.com/path?query=world' + self.assertRaises(InsecureTransportError, client.add_token, insecure_uri, + body=self.body, + headers=self.headers, + draft=1) + # Expired Token + expired = 523549800 + expired_token = { + 'expires_at': expired, + } + client = Client(self.client_id, token=expired_token, token_type="MAC", + access_token=self.access_token, mac_key=self.mac_key, + mac_algorithm="hmac-sha-1") + self.assertRaises(TokenExpiredError, client.add_token, self.uri, + body=self.body, + headers=self.headers, + draft=1) + + def test_revocation_request(self): + client = Client(self.client_id) + + url = 'https://example.com/revoke' + token = 'foobar' + + # Valid request + u, h, b = client.prepare_token_revocation_request(url, token) + self.assertEqual(u, url) + self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(b, 'token=%s&token_type_hint=access_token' % token) + + # Non-HTTPS revocation endpoint + self.assertRaises(InsecureTransportError, + client.prepare_token_revocation_request, + 'http://example.com/revoke', token) + + + u, h, b = client.prepare_token_revocation_request( + url, token, token_type_hint='refresh_token') + self.assertEqual(u, url) + self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(b, 'token=%s&token_type_hint=refresh_token' % token) + + # JSONP + u, h, b = client.prepare_token_revocation_request( + url, token, callback='hello.world') + self.assertURLEqual(u, url + '?callback=hello.world&token=%s&token_type_hint=access_token' % token) + self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(b, '') + + def test_prepare_authorization_request(self): + redirect_url = 'https://example.com/callback/' + scopes = 'read' + auth_url = 'https://example.com/authorize/' + state = 'fake_state' + + client = Client(self.client_id, redirect_url=redirect_url, scope=scopes, state=state) + + # Non-HTTPS + self.assertRaises(InsecureTransportError, + client.prepare_authorization_request, 'http://example.com/authorize/') + + # NotImplementedError + self.assertRaises(NotImplementedError, client.prepare_authorization_request, auth_url) + + def test_prepare_token_request(self): + redirect_url = 'https://example.com/callback/' + scopes = 'read' + token_url = 'https://example.com/token/' + state = 'fake_state' + + client = Client(self.client_id, scope=scopes, state=state) + + # Non-HTTPS + self.assertRaises(InsecureTransportError, + client.prepare_token_request, 'http://example.com/token/') + + # NotImplementedError + self.assertRaises(NotImplementedError, client.prepare_token_request, token_url) + + def test_prepare_refresh_token_request(self): + client = Client(self.client_id) + + url = 'https://example.com/revoke' + token = 'foobar' + scope = 'extra_scope' + + u, h, b = client.prepare_refresh_token_request(url, token) + self.assertEqual(u, url) + self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertFormBodyEqual(b, 'grant_type=refresh_token&refresh_token=%s' % token) + + # Non-HTTPS revocation endpoint + self.assertRaises(InsecureTransportError, + client.prepare_refresh_token_request, + 'http://example.com/revoke', token) + + # provide extra scope + u, h, b = client.prepare_refresh_token_request(url, token, scope=scope) + self.assertEqual(u, url) + self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertFormBodyEqual(b, 'grant_type=refresh_token&scope={}&refresh_token={}'.format(scope, token)) + + # provide scope while init + client = Client(self.client_id, scope=scope) + u, h, b = client.prepare_refresh_token_request(url, token, scope=scope) + self.assertEqual(u, url) + self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertFormBodyEqual(b, 'grant_type=refresh_token&scope={}&refresh_token={}'.format(scope, token)) + + def test_parse_token_response_invalid_expires_at(self): + token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_at":"2006-01-02T15:04:05Z",' + ' "scope":"/profile",' + ' "example_parameter":"example_value"}') + token = { + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_at": "2006-01-02T15:04:05Z", + "scope": ["/profile"], + "example_parameter": "example_value" + } + + client = Client(self.client_id) + + # Parse code and state + response = client.parse_request_body_response(token_json, scope=["/profile"]) + self.assertEqual(response, token) + self.assertEqual(None, client._expires_at) + self.assertEqual(client.access_token, response.get("access_token")) + self.assertEqual(client.refresh_token, response.get("refresh_token")) + self.assertEqual(client.token_type, response.get("token_type")) + + + def test_create_code_verifier_min_length(self): + client = Client(self.client_id) + length = 43 + code_verifier = client.create_code_verifier(length=length) + self.assertEqual(client.code_verifier, code_verifier) + + def test_create_code_verifier_max_length(self): + client = Client(self.client_id) + length = 128 + code_verifier = client.create_code_verifier(length=length) + self.assertEqual(client.code_verifier, code_verifier) + + def test_create_code_challenge_plain(self): + client = Client(self.client_id) + code_verifier = client.create_code_verifier(length=128) + code_challenge_plain = client.create_code_challenge(code_verifier=code_verifier) + + # if no code_challenge_method specified, code_challenge = code_verifier + self.assertEqual(code_challenge_plain, client.code_verifier) + self.assertEqual(client.code_challenge_method, "plain") + + def test_create_code_challenge_s256(self): + client = Client(self.client_id) + code_verifier = client.create_code_verifier(length=128) + code_challenge_s256 = client.create_code_challenge(code_verifier=code_verifier, code_challenge_method='S256') + self.assertEqual(code_challenge_s256, client.code_challenge) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_legacy_application.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_legacy_application.py new file mode 100644 index 0000000000..b5a18194b7 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_legacy_application.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +import os +import urllib.parse as urlparse +from unittest.mock import patch + +from oauthlib import signals +from oauthlib.oauth2 import LegacyApplicationClient + +from tests.unittest import TestCase + + +@patch('time.time', new=lambda: 1000) +class LegacyApplicationClientTest(TestCase): + + client_id = "someclientid" + client_secret = 'someclientsecret' + scope = ["/profile"] + kwargs = { + "some": "providers", + "require": "extra arguments" + } + + username = "user_username" + password = "user_password" + body = "not=empty" + + body_up = "not=empty&grant_type=password&username={}&password={}".format(username, password) + body_kwargs = body_up + "&some=providers&require=extra+arguments" + + token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_in":3600,' + ' "scope":"/profile",' + ' "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter":"example_value"}') + token = { + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "expires_at": 4600, + "scope": scope, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value" + } + + def test_request_body(self): + client = LegacyApplicationClient(self.client_id) + + # Basic, no extra arguments + body = client.prepare_request_body(self.username, self.password, + body=self.body) + self.assertFormBodyEqual(body, self.body_up) + + # With extra parameters + body = client.prepare_request_body(self.username, self.password, + body=self.body, **self.kwargs) + self.assertFormBodyEqual(body, self.body_kwargs) + + def test_parse_token_response(self): + client = LegacyApplicationClient(self.client_id) + + # Parse code and state + response = client.parse_request_body_response(self.token_json, scope=self.scope) + self.assertEqual(response, self.token) + self.assertEqual(client.access_token, response.get("access_token")) + self.assertEqual(client.refresh_token, response.get("refresh_token")) + self.assertEqual(client.token_type, response.get("token_type")) + + # Mismatching state + self.assertRaises(Warning, client.parse_request_body_response, self.token_json, scope="invalid") + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '5' + token = client.parse_request_body_response(self.token_json, scope="invalid") + self.assertTrue(token.scope_changed) + + scope_changes_recorded = [] + def record_scope_change(sender, message, old, new): + scope_changes_recorded.append((message, old, new)) + + signals.scope_changed.connect(record_scope_change) + try: + client.parse_request_body_response(self.token_json, scope="invalid") + self.assertEqual(len(scope_changes_recorded), 1) + message, old, new = scope_changes_recorded[0] + self.assertEqual(message, 'Scope has changed from "invalid" to "/profile".') + self.assertEqual(old, ['invalid']) + self.assertEqual(new, ['/profile']) + finally: + signals.scope_changed.disconnect(record_scope_change) + del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] + + def test_prepare_request_body(self): + """ + see issue #585 + https://github.com/oauthlib/oauthlib/issues/585 + """ + client = LegacyApplicationClient(self.client_id) + + # scenario 1, default behavior to not include `client_id` + r1 = client.prepare_request_body(username=self.username, password=self.password) + self.assertIn(r1, ('grant_type=password&username={}&password={}'.format(self.username, self.password), + 'grant_type=password&password={}&username={}'.format(self.password, self.username), + )) + + # scenario 2, include `client_id` in the body + r2 = client.prepare_request_body(username=self.username, password=self.password, include_client_id=True) + r2_params = dict(urlparse.parse_qsl(r2, keep_blank_values=True)) + self.assertEqual(len(r2_params.keys()), 4) + self.assertEqual(r2_params['grant_type'], 'password') + self.assertEqual(r2_params['username'], self.username) + self.assertEqual(r2_params['password'], self.password) + self.assertEqual(r2_params['client_id'], self.client_id) + + # scenario 3, include `client_id` + `client_secret` in the body + r3 = client.prepare_request_body(username=self.username, password=self.password, include_client_id=True, client_secret=self.client_secret) + r3_params = dict(urlparse.parse_qsl(r3, keep_blank_values=True)) + self.assertEqual(len(r3_params.keys()), 5) + self.assertEqual(r3_params['grant_type'], 'password') + self.assertEqual(r3_params['username'], self.username) + self.assertEqual(r3_params['password'], self.password) + self.assertEqual(r3_params['client_id'], self.client_id) + self.assertEqual(r3_params['client_secret'], self.client_secret) + + # scenario 4, `client_secret` is an empty string + r4 = client.prepare_request_body(username=self.username, password=self.password, include_client_id=True, client_secret='') + r4_params = dict(urlparse.parse_qsl(r4, keep_blank_values=True)) + self.assertEqual(len(r4_params.keys()), 5) + self.assertEqual(r4_params['grant_type'], 'password') + self.assertEqual(r4_params['username'], self.username) + self.assertEqual(r4_params['password'], self.password) + self.assertEqual(r4_params['client_id'], self.client_id) + self.assertEqual(r4_params['client_secret'], '') + + # scenario 4b`,` client_secret is `None` + r4b = client.prepare_request_body(username=self.username, password=self.password, include_client_id=True, client_secret=None) + r4b_params = dict(urlparse.parse_qsl(r4b, keep_blank_values=True)) + self.assertEqual(len(r4b_params.keys()), 4) + self.assertEqual(r4b_params['grant_type'], 'password') + self.assertEqual(r4b_params['username'], self.username) + self.assertEqual(r4b_params['password'], self.password) + self.assertEqual(r4b_params['client_id'], self.client_id) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_mobile_application.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_mobile_application.py new file mode 100644 index 0000000000..c40950c978 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_mobile_application.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +import os +from unittest.mock import patch + +from oauthlib import signals +from oauthlib.oauth2 import MobileApplicationClient + +from tests.unittest import TestCase + + +@patch('time.time', new=lambda: 1000) +class MobileApplicationClientTest(TestCase): + + client_id = "someclientid" + uri = "https://example.com/path?query=world" + uri_id = uri + "&response_type=token&client_id=" + client_id + uri_redirect = uri_id + "&redirect_uri=http%3A%2F%2Fmy.page.com%2Fcallback" + redirect_uri = "http://my.page.com/callback" + scope = ["/profile"] + state = "xyz" + uri_scope = uri_id + "&scope=%2Fprofile" + uri_state = uri_id + "&state=" + state + kwargs = { + "some": "providers", + "require": "extra arguments" + } + uri_kwargs = uri_id + "&some=providers&require=extra+arguments" + + code = "zzzzaaaa" + + response_uri = ('https://client.example.com/cb?#' + 'access_token=2YotnFZFEjr1zCsicMWpAA&' + 'token_type=example&' + 'expires_in=3600&' + 'scope=%2Fprofile&' + 'example_parameter=example_value') + token = { + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "expires_at": 4600, + "scope": scope, + "example_parameter": "example_value" + } + + def test_implicit_token_uri(self): + client = MobileApplicationClient(self.client_id) + + # Basic, no extra arguments + uri = client.prepare_request_uri(self.uri) + self.assertURLEqual(uri, self.uri_id) + + # With redirection uri + uri = client.prepare_request_uri(self.uri, redirect_uri=self.redirect_uri) + self.assertURLEqual(uri, self.uri_redirect) + + # With scope + uri = client.prepare_request_uri(self.uri, scope=self.scope) + self.assertURLEqual(uri, self.uri_scope) + + # With state + uri = client.prepare_request_uri(self.uri, state=self.state) + self.assertURLEqual(uri, self.uri_state) + + # With extra parameters through kwargs + uri = client.prepare_request_uri(self.uri, **self.kwargs) + self.assertURLEqual(uri, self.uri_kwargs) + + def test_populate_attributes(self): + + client = MobileApplicationClient(self.client_id) + + response_uri = (self.response_uri + "&code=EVIL-CODE") + + client.parse_request_uri_response(response_uri, scope=self.scope) + + # We must not accidentally pick up any further security + # credentials at this point. + self.assertIsNone(client.code) + + def test_parse_token_response(self): + client = MobileApplicationClient(self.client_id) + + # Parse code and state + response = client.parse_request_uri_response(self.response_uri, scope=self.scope) + self.assertEqual(response, self.token) + self.assertEqual(client.access_token, response.get("access_token")) + self.assertEqual(client.refresh_token, response.get("refresh_token")) + self.assertEqual(client.token_type, response.get("token_type")) + + # Mismatching scope + self.assertRaises(Warning, client.parse_request_uri_response, self.response_uri, scope="invalid") + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '4' + token = client.parse_request_uri_response(self.response_uri, scope='invalid') + self.assertTrue(token.scope_changed) + + scope_changes_recorded = [] + def record_scope_change(sender, message, old, new): + scope_changes_recorded.append((message, old, new)) + + signals.scope_changed.connect(record_scope_change) + try: + client.parse_request_uri_response(self.response_uri, scope="invalid") + self.assertEqual(len(scope_changes_recorded), 1) + message, old, new = scope_changes_recorded[0] + self.assertEqual(message, 'Scope has changed from "invalid" to "/profile".') + self.assertEqual(old, ['invalid']) + self.assertEqual(new, ['/profile']) + finally: + signals.scope_changed.disconnect(record_scope_change) + del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_service_application.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_service_application.py new file mode 100644 index 0000000000..b97d8554ed --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_service_application.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +import os +from time import time +from unittest.mock import patch + +import jwt + +from oauthlib.common import Request +from oauthlib.oauth2 import ServiceApplicationClient + +from tests.unittest import TestCase + + +class ServiceApplicationClientTest(TestCase): + + gt = ServiceApplicationClient.grant_type + + private_key = """ +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDk1/bxyS8Q8jiheHeYYp/4rEKJopeQRRKKpZI4s5i+UPwVpupG +AlwXWfzXwSMaKPAoKJNdu7tqKRniqst5uoHXw98gj0x7zamu0Ck1LtQ4c7pFMVah +5IYGhBi2E9ycNS329W27nJPWNCbESTu7snVlG8V8mfvGGg3xNjTMO7IdrwIDAQAB +AoGBAOQ2KuH8S5+OrsL4K+wfjoCi6MfxCUyqVU9GxocdM1m30WyWRFMEz2nKJ8fR +p3vTD4w8yplTOhcoXdQZl0kRoaDzrcYkm2VvJtQRrX7dKFT8dR8D/Tr7dNQLOXfC +DY6xveQczE7qt7Vk7lp4FqmxBsaaEuokt78pOOjywZoInjZhAkEA9wz3zoZNT0/i +rf6qv2qTIeieUB035N3dyw6f1BGSWYaXSuerDCD/J1qZbAPKKhyHZbVawFt3UMhe +542UftBaxQJBAO0iJy1I8GQjGnS7B3yvyH3CcLYGy296+XO/2xKp/d/ty1OIeovx +C60pLNwuFNF3z9d2GVQAdoQ89hUkOtjZLeMCQQD0JO6oPHUeUjYT+T7ImAv7UKVT +Suy30sKjLzqoGw1kR+wv7C5PeDRvscs4wa4CW9s6mjSrMDkDrmCLuJDtmf55AkEA +kmaMg2PNrjUR51F0zOEFycaaqXbGcFwe1/xx9zLmHzMDXd4bsnwt9kk+fe0hQzVS +JzatanQit3+feev1PN3QewJAWv4RZeavEUhKv+kLe95Yd0su7lTLVduVgh4v5yLT +Ga6FHdjGPcfajt+nrpB1n8UQBEH9ZxniokR/IPvdMlxqXA== +-----END RSA PRIVATE KEY----- +""" + + public_key = """ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDk1/bxyS8Q8jiheHeYYp/4rEKJ +opeQRRKKpZI4s5i+UPwVpupGAlwXWfzXwSMaKPAoKJNdu7tqKRniqst5uoHXw98g +j0x7zamu0Ck1LtQ4c7pFMVah5IYGhBi2E9ycNS329W27nJPWNCbESTu7snVlG8V8 +mfvGGg3xNjTMO7IdrwIDAQAB +-----END PUBLIC KEY----- +""" + + subject = 'resource-owner@provider.com' + + issuer = 'the-client@provider.com' + + audience = 'https://provider.com/token' + + client_id = "someclientid" + scope = ["/profile"] + kwargs = { + "some": "providers", + "require": "extra arguments" + } + + body = "isnot=empty" + + body_up = "not=empty&grant_type=%s" % gt + body_kwargs = body_up + "&some=providers&require=extra+arguments" + + token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_in":3600,' + ' "scope":"/profile",' + ' "example_parameter":"example_value"}') + token = { + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "scope": ["/profile"], + "example_parameter": "example_value" + } + + @patch('time.time') + def test_request_body(self, t): + t.return_value = time() + self.token['expires_at'] = self.token['expires_in'] + t.return_value + + client = ServiceApplicationClient( + self.client_id, private_key=self.private_key) + + # Basic with min required params + body = client.prepare_request_body(issuer=self.issuer, + subject=self.subject, + audience=self.audience, + body=self.body) + r = Request('https://a.b', body=body) + self.assertEqual(r.isnot, 'empty') + self.assertEqual(r.grant_type, ServiceApplicationClient.grant_type) + + claim = jwt.decode(r.assertion, self.public_key, audience=self.audience, algorithms=['RS256']) + + self.assertEqual(claim['iss'], self.issuer) + # audience verification is handled during decode now + self.assertEqual(claim['sub'], self.subject) + self.assertEqual(claim['iat'], int(t.return_value)) + self.assertNotIn('nbf', claim) + self.assertNotIn('jti', claim) + + # Missing issuer parameter + self.assertRaises(ValueError, client.prepare_request_body, + issuer=None, subject=self.subject, audience=self.audience, body=self.body) + + # Missing subject parameter + self.assertRaises(ValueError, client.prepare_request_body, + issuer=self.issuer, subject=None, audience=self.audience, body=self.body) + + # Missing audience parameter + self.assertRaises(ValueError, client.prepare_request_body, + issuer=self.issuer, subject=self.subject, audience=None, body=self.body) + + # Optional kwargs + not_before = time() - 3600 + jwt_id = '8zd15df4s35f43sd' + body = client.prepare_request_body(issuer=self.issuer, + subject=self.subject, + audience=self.audience, + body=self.body, + not_before=not_before, + jwt_id=jwt_id) + + r = Request('https://a.b', body=body) + self.assertEqual(r.isnot, 'empty') + self.assertEqual(r.grant_type, ServiceApplicationClient.grant_type) + + claim = jwt.decode(r.assertion, self.public_key, audience=self.audience, algorithms=['RS256']) + + self.assertEqual(claim['iss'], self.issuer) + # audience verification is handled during decode now + self.assertEqual(claim['sub'], self.subject) + self.assertEqual(claim['iat'], int(t.return_value)) + self.assertEqual(claim['nbf'], not_before) + self.assertEqual(claim['jti'], jwt_id) + + @patch('time.time') + def test_request_body_no_initial_private_key(self, t): + t.return_value = time() + self.token['expires_at'] = self.token['expires_in'] + t.return_value + + client = ServiceApplicationClient( + self.client_id, private_key=None) + + # Basic with private key provided + body = client.prepare_request_body(issuer=self.issuer, + subject=self.subject, + audience=self.audience, + body=self.body, + private_key=self.private_key) + r = Request('https://a.b', body=body) + self.assertEqual(r.isnot, 'empty') + self.assertEqual(r.grant_type, ServiceApplicationClient.grant_type) + + claim = jwt.decode(r.assertion, self.public_key, audience=self.audience, algorithms=['RS256']) + + self.assertEqual(claim['iss'], self.issuer) + # audience verification is handled during decode now + self.assertEqual(claim['sub'], self.subject) + self.assertEqual(claim['iat'], int(t.return_value)) + + # No private key provided + self.assertRaises(ValueError, client.prepare_request_body, + issuer=self.issuer, subject=self.subject, audience=self.audience, body=self.body) + + @patch('time.time') + def test_parse_token_response(self, t): + t.return_value = time() + self.token['expires_at'] = self.token['expires_in'] + t.return_value + + client = ServiceApplicationClient(self.client_id) + + # Parse code and state + response = client.parse_request_body_response(self.token_json, scope=self.scope) + self.assertEqual(response, self.token) + self.assertEqual(client.access_token, response.get("access_token")) + self.assertEqual(client.refresh_token, response.get("refresh_token")) + self.assertEqual(client.token_type, response.get("token_type")) + + # Mismatching state + self.assertRaises(Warning, client.parse_request_body_response, self.token_json, scope="invalid") + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '2' + token = client.parse_request_body_response(self.token_json, scope="invalid") + self.assertTrue(token.scope_changed) + del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_web_application.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_web_application.py new file mode 100644 index 0000000000..7a71121512 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/clients/test_web_application.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +import os +import urllib.parse as urlparse +import warnings +from unittest.mock import patch + +from oauthlib import common, signals +from oauthlib.oauth2 import ( + BackendApplicationClient, Client, LegacyApplicationClient, + MobileApplicationClient, WebApplicationClient, +) +from oauthlib.oauth2.rfc6749 import errors, utils +from oauthlib.oauth2.rfc6749.clients import AUTH_HEADER, BODY, URI_QUERY + +from tests.unittest import TestCase + + +@patch('time.time', new=lambda: 1000) +class WebApplicationClientTest(TestCase): + + client_id = "someclientid" + client_secret = 'someclientsecret' + uri = "https://example.com/path?query=world" + uri_id = uri + "&response_type=code&client_id=" + client_id + uri_redirect = uri_id + "&redirect_uri=http%3A%2F%2Fmy.page.com%2Fcallback" + redirect_uri = "http://my.page.com/callback" + code_verifier = "code_verifier" + scope = ["/profile"] + state = "xyz" + code_challenge = "code_challenge" + code_challenge_method = "S256" + uri_scope = uri_id + "&scope=%2Fprofile" + uri_state = uri_id + "&state=" + state + uri_code_challenge = uri_id + "&code_challenge=" + code_challenge + "&code_challenge_method=" + code_challenge_method + uri_code_challenge_method = uri_id + "&code_challenge=" + code_challenge + "&code_challenge_method=plain" + kwargs = { + "some": "providers", + "require": "extra arguments" + } + uri_kwargs = uri_id + "&some=providers&require=extra+arguments" + uri_authorize_code = uri_redirect + "&scope=%2Fprofile&state=" + state + + code = "zzzzaaaa" + body = "not=empty" + + body_code = "not=empty&grant_type=authorization_code&code={}&client_id={}".format(code, client_id) + body_redirect = body_code + "&redirect_uri=http%3A%2F%2Fmy.page.com%2Fcallback" + body_code_verifier = body_code + "&code_verifier=code_verifier" + body_kwargs = body_code + "&some=providers&require=extra+arguments" + + response_uri = "https://client.example.com/cb?code=zzzzaaaa&state=xyz" + response = {"code": "zzzzaaaa", "state": "xyz"} + + token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_in":3600,' + ' "scope":"/profile",' + ' "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter":"example_value"}') + token = { + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "expires_at": 4600, + "scope": scope, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value" + } + + def test_auth_grant_uri(self): + client = WebApplicationClient(self.client_id) + + # Basic, no extra arguments + uri = client.prepare_request_uri(self.uri) + self.assertURLEqual(uri, self.uri_id) + + # With redirection uri + uri = client.prepare_request_uri(self.uri, redirect_uri=self.redirect_uri) + self.assertURLEqual(uri, self.uri_redirect) + + # With scope + uri = client.prepare_request_uri(self.uri, scope=self.scope) + self.assertURLEqual(uri, self.uri_scope) + + # With state + uri = client.prepare_request_uri(self.uri, state=self.state) + self.assertURLEqual(uri, self.uri_state) + + # with code_challenge and code_challenge_method + uri = client.prepare_request_uri(self.uri, code_challenge=self.code_challenge, code_challenge_method=self.code_challenge_method) + self.assertURLEqual(uri, self.uri_code_challenge) + + # with no code_challenge_method + uri = client.prepare_request_uri(self.uri, code_challenge=self.code_challenge) + self.assertURLEqual(uri, self.uri_code_challenge_method) + + # With extra parameters through kwargs + uri = client.prepare_request_uri(self.uri, **self.kwargs) + self.assertURLEqual(uri, self.uri_kwargs) + + def test_request_body(self): + client = WebApplicationClient(self.client_id, code=self.code) + + # Basic, no extra arguments + body = client.prepare_request_body(body=self.body) + self.assertFormBodyEqual(body, self.body_code) + + rclient = WebApplicationClient(self.client_id) + body = rclient.prepare_request_body(code=self.code, body=self.body) + self.assertFormBodyEqual(body, self.body_code) + + # With redirection uri + body = client.prepare_request_body(body=self.body, redirect_uri=self.redirect_uri) + self.assertFormBodyEqual(body, self.body_redirect) + + # With code verifier + body = client.prepare_request_body(body=self.body, code_verifier=self.code_verifier) + self.assertFormBodyEqual(body, self.body_code_verifier) + + # With extra parameters + body = client.prepare_request_body(body=self.body, **self.kwargs) + self.assertFormBodyEqual(body, self.body_kwargs) + + def test_parse_grant_uri_response(self): + client = WebApplicationClient(self.client_id) + + # Parse code and state + response = client.parse_request_uri_response(self.response_uri, state=self.state) + self.assertEqual(response, self.response) + self.assertEqual(client.code, self.code) + + # Mismatching state + self.assertRaises(errors.MismatchingStateError, + client.parse_request_uri_response, + self.response_uri, + state="invalid") + + def test_populate_attributes(self): + + client = WebApplicationClient(self.client_id) + + response_uri = (self.response_uri + + "&access_token=EVIL-TOKEN" + "&refresh_token=EVIL-TOKEN" + "&mac_key=EVIL-KEY") + + client.parse_request_uri_response(response_uri, self.state) + + self.assertEqual(client.code, self.code) + + # We must not accidentally pick up any further security + # credentials at this point. + self.assertIsNone(client.access_token) + self.assertIsNone(client.refresh_token) + self.assertIsNone(client.mac_key) + + def test_parse_token_response(self): + client = WebApplicationClient(self.client_id) + + # Parse code and state + response = client.parse_request_body_response(self.token_json, scope=self.scope) + self.assertEqual(response, self.token) + self.assertEqual(client.access_token, response.get("access_token")) + self.assertEqual(client.refresh_token, response.get("refresh_token")) + self.assertEqual(client.token_type, response.get("token_type")) + + # Mismatching state + self.assertRaises(Warning, client.parse_request_body_response, self.token_json, scope="invalid") + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' + token = client.parse_request_body_response(self.token_json, scope="invalid") + self.assertTrue(token.scope_changed) + + scope_changes_recorded = [] + def record_scope_change(sender, message, old, new): + scope_changes_recorded.append((message, old, new)) + + signals.scope_changed.connect(record_scope_change) + try: + client.parse_request_body_response(self.token_json, scope="invalid") + self.assertEqual(len(scope_changes_recorded), 1) + message, old, new = scope_changes_recorded[0] + self.assertEqual(message, 'Scope has changed from "invalid" to "/profile".') + self.assertEqual(old, ['invalid']) + self.assertEqual(new, ['/profile']) + finally: + signals.scope_changed.disconnect(record_scope_change) + del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] + + def test_prepare_authorization_requeset(self): + client = WebApplicationClient(self.client_id) + + url, header, body = client.prepare_authorization_request( + self.uri, redirect_url=self.redirect_uri, state=self.state, scope=self.scope) + self.assertURLEqual(url, self.uri_authorize_code) + # verify default header and body only + self.assertEqual(header, {'Content-Type': 'application/x-www-form-urlencoded'}) + self.assertEqual(body, '') + + def test_prepare_request_body(self): + """ + see issue #585 + https://github.com/oauthlib/oauthlib/issues/585 + + `prepare_request_body` should support the following scenarios: + 1. Include client_id alone in the body (default) + 2. Include client_id and client_secret in auth and not include them in the body (RFC preferred solution) + 3. Include client_id and client_secret in the body (RFC alternative solution) + 4. Include client_id in the body and an empty string for client_secret. + """ + client = WebApplicationClient(self.client_id) + + # scenario 1, default behavior to include `client_id` + r1 = client.prepare_request_body() + self.assertEqual(r1, 'grant_type=authorization_code&client_id=%s' % self.client_id) + + r1b = client.prepare_request_body(include_client_id=True) + self.assertEqual(r1b, 'grant_type=authorization_code&client_id=%s' % self.client_id) + + # scenario 2, do not include `client_id` in the body, so it can be sent in auth. + r2 = client.prepare_request_body(include_client_id=False) + self.assertEqual(r2, 'grant_type=authorization_code') + + # scenario 3, Include client_id and client_secret in the body (RFC alternative solution) + # the order of kwargs being appended is not guaranteed. for brevity, check the 2 permutations instead of sorting + r3 = client.prepare_request_body(client_secret=self.client_secret) + r3_params = dict(urlparse.parse_qsl(r3, keep_blank_values=True)) + self.assertEqual(len(r3_params.keys()), 3) + self.assertEqual(r3_params['grant_type'], 'authorization_code') + self.assertEqual(r3_params['client_id'], self.client_id) + self.assertEqual(r3_params['client_secret'], self.client_secret) + + r3b = client.prepare_request_body(include_client_id=True, client_secret=self.client_secret) + r3b_params = dict(urlparse.parse_qsl(r3b, keep_blank_values=True)) + self.assertEqual(len(r3b_params.keys()), 3) + self.assertEqual(r3b_params['grant_type'], 'authorization_code') + self.assertEqual(r3b_params['client_id'], self.client_id) + self.assertEqual(r3b_params['client_secret'], self.client_secret) + + # scenario 4, `client_secret` is an empty string + r4 = client.prepare_request_body(include_client_id=True, client_secret='') + r4_params = dict(urlparse.parse_qsl(r4, keep_blank_values=True)) + self.assertEqual(len(r4_params.keys()), 3) + self.assertEqual(r4_params['grant_type'], 'authorization_code') + self.assertEqual(r4_params['client_id'], self.client_id) + self.assertEqual(r4_params['client_secret'], '') + + # scenario 4b, `client_secret` is `None` + r4b = client.prepare_request_body(include_client_id=True, client_secret=None) + r4b_params = dict(urlparse.parse_qsl(r4b, keep_blank_values=True)) + self.assertEqual(len(r4b_params.keys()), 2) + self.assertEqual(r4b_params['grant_type'], 'authorization_code') + self.assertEqual(r4b_params['client_id'], self.client_id) + + # scenario Warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") # catch all + + # warning1 - raise a DeprecationWarning if a `client_id` is submitted + rWarnings1 = client.prepare_request_body(client_id=self.client_id) + self.assertEqual(len(w), 1) + self.assertIsInstance(w[0].message, DeprecationWarning) + + # testing the exact warning message in Python2&Python3 is a pain + + # scenario Exceptions + # exception1 - raise a ValueError if the a different `client_id` is submitted + with self.assertRaises(ValueError) as cm: + client.prepare_request_body(client_id='different_client_id') + # testing the exact exception message in Python2&Python3 is a pain diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/__init__.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_base_endpoint.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_base_endpoint.py new file mode 100644 index 0000000000..b1af6c3306 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_base_endpoint.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +from oauthlib.oauth2 import ( + FatalClientError, OAuth2Error, RequestValidator, Server, +) +from oauthlib.oauth2.rfc6749 import ( + BaseEndpoint, catch_errors_and_unavailability, +) + +from tests.unittest import TestCase + + +class BaseEndpointTest(TestCase): + + def test_default_config(self): + endpoint = BaseEndpoint() + self.assertFalse(endpoint.catch_errors) + self.assertTrue(endpoint.available) + endpoint.catch_errors = True + self.assertTrue(endpoint.catch_errors) + endpoint.available = False + self.assertFalse(endpoint.available) + + def test_error_catching(self): + validator = RequestValidator() + server = Server(validator) + server.catch_errors = True + h, b, s = server.create_token_response( + 'https://example.com', body='grant_type=authorization_code&code=abc' + ) + self.assertIn("server_error", b) + self.assertEqual(s, 500) + + def test_unavailability(self): + validator = RequestValidator() + server = Server(validator) + server.available = False + h, b, s = server.create_authorization_response('https://example.com') + self.assertIn("temporarily_unavailable", b) + self.assertEqual(s, 503) + + def test_wrapper(self): + + class TestServer(Server): + + @catch_errors_and_unavailability + def throw_error(self, uri): + raise ValueError() + + @catch_errors_and_unavailability + def throw_oauth_error(self, uri): + raise OAuth2Error() + + @catch_errors_and_unavailability + def throw_fatal_oauth_error(self, uri): + raise FatalClientError() + + validator = RequestValidator() + server = TestServer(validator) + + server.catch_errors = True + h, b, s = server.throw_error('a') + self.assertIn("server_error", b) + self.assertEqual(s, 500) + + server.available = False + h, b, s = server.throw_error('a') + self.assertIn("temporarily_unavailable", b) + self.assertEqual(s, 503) + + server.available = True + self.assertRaises(OAuth2Error, server.throw_oauth_error, 'a') + self.assertRaises(FatalClientError, server.throw_fatal_oauth_error, 'a') + server.catch_errors = False + self.assertRaises(OAuth2Error, server.throw_oauth_error, 'a') + self.assertRaises(FatalClientError, server.throw_fatal_oauth_error, 'a') diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_client_authentication.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_client_authentication.py new file mode 100644 index 0000000000..0659ee0d25 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_client_authentication.py @@ -0,0 +1,162 @@ +"""Client authentication tests across all endpoints. + +Client authentication in OAuth2 serve two purposes, to authenticate +confidential clients and to ensure public clients are in fact public. The +latter is achieved with authenticate_client_id and the former with +authenticate_client. + +We make sure authentication is done by requiring a client object to be set +on the request object with a client_id parameter. The client_id attribute +prevents this check from being circumvented with a client form parameter. +""" +import json +from unittest import mock + +from oauthlib.oauth2 import ( + BackendApplicationServer, LegacyApplicationServer, MobileApplicationServer, + RequestValidator, WebApplicationServer, +) + +from tests.unittest import TestCase + +from .test_utils import get_fragment_credentials + + +class ClientAuthenticationTest(TestCase): + + def inspect_client(self, request, refresh_token=False): + if not request.client or not request.client.client_id: + raise ValueError() + return 'abc' + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.is_pkce_required.return_value = False + self.validator.get_code_challenge.return_value = None + self.validator.get_default_redirect_uri.return_value = 'http://i.b./path' + self.web = WebApplicationServer(self.validator, + token_generator=self.inspect_client) + self.mobile = MobileApplicationServer(self.validator, + token_generator=self.inspect_client) + self.legacy = LegacyApplicationServer(self.validator, + token_generator=self.inspect_client) + self.backend = BackendApplicationServer(self.validator, + token_generator=self.inspect_client) + self.token_uri = 'http://example.com/path' + self.auth_uri = 'http://example.com/path?client_id=abc&response_type=token' + # should be base64 but no added value in this unittest + self.basicauth_client_creds = {"Authorization": "john:doe"} + self.basicauth_client_id = {"Authorization": "john:"} + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def set_client_id(self, client_id, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def basicauth_authenticate_client(self, request): + assert "Authorization" in request.headers + assert "john:doe" in request.headers["Authorization"] + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def test_client_id_authentication(self): + token_uri = 'http://example.com/path' + + # authorization code grant + self.validator.authenticate_client.return_value = False + self.validator.authenticate_client_id.return_value = False + _, body, _ = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=mock') + self.assertEqual(json.loads(body)['error'], 'invalid_client') + + self.validator.authenticate_client_id.return_value = True + self.validator.authenticate_client.side_effect = self.set_client + _, body, _ = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=mock') + self.assertIn('access_token', json.loads(body)) + + # implicit grant + auth_uri = 'http://example.com/path?client_id=abc&response_type=token' + self.assertRaises(ValueError, self.mobile.create_authorization_response, + auth_uri, scopes=['random']) + + self.validator.validate_client_id.side_effect = self.set_client_id + h, _, s = self.mobile.create_authorization_response(auth_uri, scopes=['random']) + self.assertEqual(302, s) + self.assertIn('Location', h) + self.assertIn('access_token', get_fragment_credentials(h['Location'])) + + def test_basicauth_web(self): + self.validator.authenticate_client.side_effect = self.basicauth_authenticate_client + _, body, _ = self.web.create_token_response( + self.token_uri, + body='grant_type=authorization_code&code=mock', + headers=self.basicauth_client_creds + ) + self.assertIn('access_token', json.loads(body)) + + def test_basicauth_legacy(self): + self.validator.authenticate_client.side_effect = self.basicauth_authenticate_client + _, body, _ = self.legacy.create_token_response( + self.token_uri, + body='grant_type=password&username=abc&password=secret', + headers=self.basicauth_client_creds + ) + self.assertIn('access_token', json.loads(body)) + + def test_basicauth_backend(self): + self.validator.authenticate_client.side_effect = self.basicauth_authenticate_client + _, body, _ = self.backend.create_token_response( + self.token_uri, + body='grant_type=client_credentials', + headers=self.basicauth_client_creds + ) + self.assertIn('access_token', json.loads(body)) + + def test_basicauth_revoke(self): + self.validator.authenticate_client.side_effect = self.basicauth_authenticate_client + + # legacy or any other uses the same RevocationEndpoint + _, body, status = self.legacy.create_revocation_response( + self.token_uri, + body='token=foobar', + headers=self.basicauth_client_creds + ) + self.assertEqual(status, 200, body) + + def test_basicauth_introspect(self): + self.validator.authenticate_client.side_effect = self.basicauth_authenticate_client + + # legacy or any other uses the same IntrospectEndpoint + _, body, status = self.legacy.create_introspect_response( + self.token_uri, + body='token=foobar', + headers=self.basicauth_client_creds + ) + self.assertEqual(status, 200, body) + + def test_custom_authentication(self): + token_uri = 'http://example.com/path' + + # authorization code grant + self.assertRaises(NotImplementedError, + self.web.create_token_response, token_uri, + body='grant_type=authorization_code&code=mock') + + # password grant + self.validator.authenticate_client.return_value = True + self.assertRaises(NotImplementedError, + self.legacy.create_token_response, token_uri, + body='grant_type=password&username=abc&password=secret') + + # client credentials grant + self.validator.authenticate_client.return_value = True + self.assertRaises(NotImplementedError, + self.backend.create_token_response, token_uri, + body='grant_type=client_credentials') diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py new file mode 100644 index 0000000000..32c770ccb7 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py @@ -0,0 +1,128 @@ +"""Ensure credentials are preserved through the authorization. + +The Authorization Code Grant will need to preserve state as well as redirect +uri and the Implicit Grant will need to preserve state. +""" +import json +from unittest import mock + +from oauthlib.oauth2 import ( + MobileApplicationServer, RequestValidator, WebApplicationServer, +) +from oauthlib.oauth2.rfc6749 import errors + +from tests.unittest import TestCase + +from .test_utils import get_fragment_credentials, get_query_credentials + + +class PreservationTest(TestCase): + + DEFAULT_REDIRECT_URI = 'http://i.b./path' + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_default_redirect_uri.return_value = self.DEFAULT_REDIRECT_URI + self.validator.get_code_challenge.return_value = None + self.validator.authenticate_client.side_effect = self.set_client + self.web = WebApplicationServer(self.validator) + self.mobile = MobileApplicationServer(self.validator) + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def test_state_preservation(self): + auth_uri = 'http://example.com/path?state=xyz&client_id=abc&response_type=' + + # authorization grant + h, _, s = self.web.create_authorization_response( + auth_uri + 'code', scopes=['random']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertEqual(get_query_credentials(h['Location'])['state'][0], 'xyz') + + # implicit grant + h, _, s = self.mobile.create_authorization_response( + auth_uri + 'token', scopes=['random']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertEqual(get_fragment_credentials(h['Location'])['state'][0], 'xyz') + + def test_redirect_uri_preservation(self): + auth_uri = 'http://example.com/path?redirect_uri=http%3A%2F%2Fi.b%2Fpath&client_id=abc' + redirect_uri = 'http://i.b/path' + token_uri = 'http://example.com/path' + + # authorization grant + h, _, s = self.web.create_authorization_response( + auth_uri + '&response_type=code', scopes=['random']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertTrue(h['Location'].startswith(redirect_uri)) + + # confirm_redirect_uri should return false if the redirect uri + # was given in the authorization but not in the token request. + self.validator.confirm_redirect_uri.return_value = False + code = get_query_credentials(h['Location'])['code'][0] + _, body, _ = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=%s' % code) + self.assertEqual(json.loads(body)['error'], 'invalid_request') + + # implicit grant + h, _, s = self.mobile.create_authorization_response( + auth_uri + '&response_type=token', scopes=['random']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertTrue(h['Location'].startswith(redirect_uri)) + + def test_invalid_redirect_uri(self): + auth_uri = 'http://example.com/path?redirect_uri=http%3A%2F%2Fi.b%2Fpath&client_id=abc' + self.validator.validate_redirect_uri.return_value = False + + # authorization grant + self.assertRaises(errors.MismatchingRedirectURIError, + self.web.create_authorization_response, + auth_uri + '&response_type=code', scopes=['random']) + + # implicit grant + self.assertRaises(errors.MismatchingRedirectURIError, + self.mobile.create_authorization_response, + auth_uri + '&response_type=token', scopes=['random']) + + def test_default_uri(self): + auth_uri = 'http://example.com/path?state=xyz&client_id=abc' + + self.validator.get_default_redirect_uri.return_value = None + + # authorization grant + self.assertRaises(errors.MissingRedirectURIError, + self.web.create_authorization_response, + auth_uri + '&response_type=code', scopes=['random']) + + # implicit grant + self.assertRaises(errors.MissingRedirectURIError, + self.mobile.create_authorization_response, + auth_uri + '&response_type=token', scopes=['random']) + + def test_default_uri_in_token(self): + auth_uri = 'http://example.com/path?state=xyz&client_id=abc' + token_uri = 'http://example.com/path' + + # authorization grant + h, _, s = self.web.create_authorization_response( + auth_uri + '&response_type=code', scopes=['random']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertTrue(h['Location'].startswith(self.DEFAULT_REDIRECT_URI)) + + # confirm_redirect_uri should return true if the redirect uri + # was not given in the authorization AND not in the token request. + self.validator.confirm_redirect_uri.return_value = True + code = get_query_credentials(h['Location'])['code'][0] + self.validator.validate_code.return_value = True + _, body, s = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=%s' % code) + self.assertEqual(s, 200) + self.assertEqual(self.validator.confirm_redirect_uri.call_args[0][2], self.DEFAULT_REDIRECT_URI) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_error_responses.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_error_responses.py new file mode 100644 index 0000000000..f61595e213 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_error_responses.py @@ -0,0 +1,491 @@ +"""Ensure the correct error responses are returned for all defined error types. +""" +import json +from unittest import mock + +from oauthlib.common import urlencode +from oauthlib.oauth2 import ( + BackendApplicationServer, LegacyApplicationServer, MobileApplicationServer, + RequestValidator, WebApplicationServer, +) +from oauthlib.oauth2.rfc6749 import errors + +from tests.unittest import TestCase + + +class ErrorResponseTest(TestCase): + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_default_redirect_uri.return_value = None + self.validator.get_code_challenge.return_value = None + self.web = WebApplicationServer(self.validator) + self.mobile = MobileApplicationServer(self.validator) + self.legacy = LegacyApplicationServer(self.validator) + self.backend = BackendApplicationServer(self.validator) + + def test_invalid_redirect_uri(self): + uri = 'https://example.com/authorize?response_type={0}&client_id=foo&redirect_uri=wrong' + + # Authorization code grant + self.assertRaises(errors.InvalidRedirectURIError, + self.web.validate_authorization_request, uri.format('code')) + self.assertRaises(errors.InvalidRedirectURIError, + self.web.create_authorization_response, uri.format('code'), scopes=['foo']) + + # Implicit grant + self.assertRaises(errors.InvalidRedirectURIError, + self.mobile.validate_authorization_request, uri.format('token')) + self.assertRaises(errors.InvalidRedirectURIError, + self.mobile.create_authorization_response, uri.format('token'), scopes=['foo']) + + def test_invalid_default_redirect_uri(self): + uri = 'https://example.com/authorize?response_type={0}&client_id=foo' + self.validator.get_default_redirect_uri.return_value = "wrong" + + # Authorization code grant + self.assertRaises(errors.InvalidRedirectURIError, + self.web.validate_authorization_request, uri.format('code')) + self.assertRaises(errors.InvalidRedirectURIError, + self.web.create_authorization_response, uri.format('code'), scopes=['foo']) + + # Implicit grant + self.assertRaises(errors.InvalidRedirectURIError, + self.mobile.validate_authorization_request, uri.format('token')) + self.assertRaises(errors.InvalidRedirectURIError, + self.mobile.create_authorization_response, uri.format('token'), scopes=['foo']) + + def test_missing_redirect_uri(self): + uri = 'https://example.com/authorize?response_type={0}&client_id=foo' + + # Authorization code grant + self.assertRaises(errors.MissingRedirectURIError, + self.web.validate_authorization_request, uri.format('code')) + self.assertRaises(errors.MissingRedirectURIError, + self.web.create_authorization_response, uri.format('code'), scopes=['foo']) + + # Implicit grant + self.assertRaises(errors.MissingRedirectURIError, + self.mobile.validate_authorization_request, uri.format('token')) + self.assertRaises(errors.MissingRedirectURIError, + self.mobile.create_authorization_response, uri.format('token'), scopes=['foo']) + + def test_mismatching_redirect_uri(self): + uri = 'https://example.com/authorize?response_type={0}&client_id=foo&redirect_uri=https%3A%2F%2Fi.b%2Fback' + + # Authorization code grant + self.validator.validate_redirect_uri.return_value = False + self.assertRaises(errors.MismatchingRedirectURIError, + self.web.validate_authorization_request, uri.format('code')) + self.assertRaises(errors.MismatchingRedirectURIError, + self.web.create_authorization_response, uri.format('code'), scopes=['foo']) + + # Implicit grant + self.assertRaises(errors.MismatchingRedirectURIError, + self.mobile.validate_authorization_request, uri.format('token')) + self.assertRaises(errors.MismatchingRedirectURIError, + self.mobile.create_authorization_response, uri.format('token'), scopes=['foo']) + + def test_missing_client_id(self): + uri = 'https://example.com/authorize?response_type={0}&redirect_uri=https%3A%2F%2Fi.b%2Fback' + + # Authorization code grant + self.validator.validate_redirect_uri.return_value = False + self.assertRaises(errors.MissingClientIdError, + self.web.validate_authorization_request, uri.format('code')) + self.assertRaises(errors.MissingClientIdError, + self.web.create_authorization_response, uri.format('code'), scopes=['foo']) + + # Implicit grant + self.assertRaises(errors.MissingClientIdError, + self.mobile.validate_authorization_request, uri.format('token')) + self.assertRaises(errors.MissingClientIdError, + self.mobile.create_authorization_response, uri.format('token'), scopes=['foo']) + + def test_invalid_client_id(self): + uri = 'https://example.com/authorize?response_type={0}&client_id=foo&redirect_uri=https%3A%2F%2Fi.b%2Fback' + + # Authorization code grant + self.validator.validate_client_id.return_value = False + self.assertRaises(errors.InvalidClientIdError, + self.web.validate_authorization_request, uri.format('code')) + self.assertRaises(errors.InvalidClientIdError, + self.web.create_authorization_response, uri.format('code'), scopes=['foo']) + + # Implicit grant + self.assertRaises(errors.InvalidClientIdError, + self.mobile.validate_authorization_request, uri.format('token')) + self.assertRaises(errors.InvalidClientIdError, + self.mobile.create_authorization_response, uri.format('token'), scopes=['foo']) + + def test_empty_parameter(self): + uri = 'https://example.com/authorize?client_id=foo&redirect_uri=https%3A%2F%2Fi.b%2Fback&response_type=code&' + + # Authorization code grant + self.assertRaises(errors.InvalidRequestFatalError, + self.web.validate_authorization_request, uri) + + # Implicit grant + self.assertRaises(errors.InvalidRequestFatalError, + self.mobile.validate_authorization_request, uri) + + def test_invalid_request(self): + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + token_uri = 'https://i.b/token' + + invalid_bodies = [ + # duplicate params + 'grant_type=authorization_code&client_id=nope&client_id=nope&code=foo' + ] + for body in invalid_bodies: + _, body, _ = self.web.create_token_response(token_uri, + body=body) + self.assertEqual('invalid_request', json.loads(body)['error']) + + # Password credentials grant + invalid_bodies = [ + # duplicate params + 'grant_type=password&username=foo&username=bar&password=baz' + # missing username + 'grant_type=password&password=baz' + # missing password + 'grant_type=password&username=foo' + ] + self.validator.authenticate_client.side_effect = self.set_client + for body in invalid_bodies: + _, body, _ = self.legacy.create_token_response(token_uri, + body=body) + self.assertEqual('invalid_request', json.loads(body)['error']) + + # Client credentials grant + invalid_bodies = [ + # duplicate params + 'grant_type=client_credentials&scope=foo&scope=bar' + ] + for body in invalid_bodies: + _, body, _ = self.backend.create_token_response(token_uri, + body=body) + self.assertEqual('invalid_request', json.loads(body)['error']) + + def test_invalid_request_duplicate_params(self): + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + uri = 'https://i.b/auth?client_id=foo&client_id=bar&response_type={0}' + description = 'Duplicate client_id parameter.' + + # Authorization code + self.assertRaisesRegex(errors.InvalidRequestFatalError, + description, + self.web.validate_authorization_request, + uri.format('code')) + self.assertRaisesRegex(errors.InvalidRequestFatalError, + description, + self.web.create_authorization_response, + uri.format('code'), scopes=['foo']) + + # Implicit grant + self.assertRaisesRegex(errors.InvalidRequestFatalError, + description, + self.mobile.validate_authorization_request, + uri.format('token')) + self.assertRaisesRegex(errors.InvalidRequestFatalError, + description, + self.mobile.create_authorization_response, + uri.format('token'), scopes=['foo']) + + def test_invalid_request_missing_response_type(self): + + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + + uri = 'https://i.b/auth?client_id=foo' + + # Authorization code + self.assertRaises(errors.MissingResponseTypeError, + self.web.validate_authorization_request, + uri.format('code')) + h, _, s = self.web.create_authorization_response(uri, scopes=['foo']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertIn('error=invalid_request', h['Location']) + + # Implicit grant + self.assertRaises(errors.MissingResponseTypeError, + self.mobile.validate_authorization_request, + uri.format('token')) + h, _, s = self.mobile.create_authorization_response(uri, scopes=['foo']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertIn('error=invalid_request', h['Location']) + + def test_unauthorized_client(self): + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + self.validator.validate_grant_type.return_value = False + self.validator.validate_response_type.return_value = False + self.validator.authenticate_client.side_effect = self.set_client + token_uri = 'https://i.b/token' + + # Authorization code grant + self.assertRaises(errors.UnauthorizedClientError, + self.web.validate_authorization_request, + 'https://i.b/auth?response_type=code&client_id=foo') + _, body, _ = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=foo') + self.assertEqual('unauthorized_client', json.loads(body)['error']) + + # Implicit grant + self.assertRaises(errors.UnauthorizedClientError, + self.mobile.validate_authorization_request, + 'https://i.b/auth?response_type=token&client_id=foo') + + # Password credentials grant + _, body, _ = self.legacy.create_token_response(token_uri, + body='grant_type=password&username=foo&password=bar') + self.assertEqual('unauthorized_client', json.loads(body)['error']) + + # Client credentials grant + _, body, _ = self.backend.create_token_response(token_uri, + body='grant_type=client_credentials') + self.assertEqual('unauthorized_client', json.loads(body)['error']) + + def test_access_denied(self): + self.validator.authenticate_client.side_effect = self.set_client + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + self.validator.confirm_redirect_uri.return_value = False + token_uri = 'https://i.b/token' + # Authorization code grant + _, body, _ = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=foo') + self.assertEqual('invalid_request', json.loads(body)['error']) + + def test_access_denied_no_default_redirecturi(self): + self.validator.authenticate_client.side_effect = self.set_client + self.validator.get_default_redirect_uri.return_value = None + token_uri = 'https://i.b/token' + # Authorization code grant + _, body, _ = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=foo') + self.assertEqual('invalid_request', json.loads(body)['error']) + + def test_unsupported_response_type(self): + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + + # Authorization code grant + self.assertRaises(errors.UnsupportedResponseTypeError, + self.web.validate_authorization_request, + 'https://i.b/auth?response_type=foo&client_id=foo') + + # Implicit grant + self.assertRaises(errors.UnsupportedResponseTypeError, + self.mobile.validate_authorization_request, + 'https://i.b/auth?response_type=foo&client_id=foo') + + def test_invalid_scope(self): + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + self.validator.validate_scopes.return_value = False + self.validator.authenticate_client.side_effect = self.set_client + + # Authorization code grant + self.assertRaises(errors.InvalidScopeError, + self.web.validate_authorization_request, + 'https://i.b/auth?response_type=code&client_id=foo') + + # Implicit grant + self.assertRaises(errors.InvalidScopeError, + self.mobile.validate_authorization_request, + 'https://i.b/auth?response_type=token&client_id=foo') + + # Password credentials grant + _, body, _ = self.legacy.create_token_response( + 'https://i.b/token', + body='grant_type=password&username=foo&password=bar') + self.assertEqual('invalid_scope', json.loads(body)['error']) + + # Client credentials grant + _, body, _ = self.backend.create_token_response( + 'https://i.b/token', + body='grant_type=client_credentials') + self.assertEqual('invalid_scope', json.loads(body)['error']) + + def test_server_error(self): + def raise_error(*args, **kwargs): + raise ValueError() + + self.validator.validate_client_id.side_effect = raise_error + self.validator.authenticate_client.side_effect = raise_error + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + + # Authorization code grant + self.web.catch_errors = True + _, _, s = self.web.create_authorization_response( + 'https://i.b/auth?client_id=foo&response_type=code', + scopes=['foo']) + self.assertEqual(s, 500) + _, _, s = self.web.create_token_response( + 'https://i.b/token', + body='grant_type=authorization_code&code=foo', + scopes=['foo']) + self.assertEqual(s, 500) + + # Implicit grant + self.mobile.catch_errors = True + _, _, s = self.mobile.create_authorization_response( + 'https://i.b/auth?client_id=foo&response_type=token', + scopes=['foo']) + self.assertEqual(s, 500) + + # Password credentials grant + self.legacy.catch_errors = True + _, _, s = self.legacy.create_token_response( + 'https://i.b/token', + body='grant_type=password&username=foo&password=foo') + self.assertEqual(s, 500) + + # Client credentials grant + self.backend.catch_errors = True + _, _, s = self.backend.create_token_response( + 'https://i.b/token', + body='grant_type=client_credentials') + self.assertEqual(s, 500) + + def test_temporarily_unavailable(self): + # Authorization code grant + self.web.available = False + _, _, s = self.web.create_authorization_response( + 'https://i.b/auth?client_id=foo&response_type=code', + scopes=['foo']) + self.assertEqual(s, 503) + _, _, s = self.web.create_token_response( + 'https://i.b/token', + body='grant_type=authorization_code&code=foo', + scopes=['foo']) + self.assertEqual(s, 503) + + # Implicit grant + self.mobile.available = False + _, _, s = self.mobile.create_authorization_response( + 'https://i.b/auth?client_id=foo&response_type=token', + scopes=['foo']) + self.assertEqual(s, 503) + + # Password credentials grant + self.legacy.available = False + _, _, s = self.legacy.create_token_response( + 'https://i.b/token', + body='grant_type=password&username=foo&password=foo') + self.assertEqual(s, 503) + + # Client credentials grant + self.backend.available = False + _, _, s = self.backend.create_token_response( + 'https://i.b/token', + body='grant_type=client_credentials') + self.assertEqual(s, 503) + + def test_invalid_client(self): + self.validator.authenticate_client.return_value = False + self.validator.authenticate_client_id.return_value = False + + # Authorization code grant + _, body, _ = self.web.create_token_response('https://i.b/token', + body='grant_type=authorization_code&code=foo') + self.assertEqual('invalid_client', json.loads(body)['error']) + + # Password credentials grant + _, body, _ = self.legacy.create_token_response('https://i.b/token', + body='grant_type=password&username=foo&password=bar') + self.assertEqual('invalid_client', json.loads(body)['error']) + + # Client credentials grant + _, body, _ = self.legacy.create_token_response('https://i.b/token', + body='grant_type=client_credentials') + self.assertEqual('invalid_client', json.loads(body)['error']) + + def test_invalid_grant(self): + self.validator.authenticate_client.side_effect = self.set_client + + # Authorization code grant + self.validator.validate_code.return_value = False + _, body, _ = self.web.create_token_response('https://i.b/token', + body='grant_type=authorization_code&code=foo') + self.assertEqual('invalid_grant', json.loads(body)['error']) + + # Password credentials grant + self.validator.validate_user.return_value = False + _, body, _ = self.legacy.create_token_response('https://i.b/token', + body='grant_type=password&username=foo&password=bar') + self.assertEqual('invalid_grant', json.loads(body)['error']) + + def test_unsupported_grant_type(self): + self.validator.authenticate_client.side_effect = self.set_client + + # Authorization code grant + _, body, _ = self.web.create_token_response('https://i.b/token', + body='grant_type=bar&code=foo') + self.assertEqual('unsupported_grant_type', json.loads(body)['error']) + + # Password credentials grant + _, body, _ = self.legacy.create_token_response('https://i.b/token', + body='grant_type=bar&username=foo&password=bar') + self.assertEqual('unsupported_grant_type', json.loads(body)['error']) + + # Client credentials grant + _, body, _ = self.backend.create_token_response('https://i.b/token', + body='grant_type=bar') + self.assertEqual('unsupported_grant_type', json.loads(body)['error']) + + def test_invalid_request_method(self): + test_methods = ['GET', 'pUt', 'dEleTe', 'paTcH'] + test_methods = test_methods + [x.lower() for x in test_methods] + [x.upper() for x in test_methods] + for method in test_methods: + self.validator.authenticate_client.side_effect = self.set_client + + uri = "http://i/b/token/" + try: + _, body, s = self.web.create_token_response(uri, + body='grant_type=access_token&code=123', http_method=method) + self.fail('This should have failed with InvalidRequestError') + except errors.InvalidRequestError as ire: + self.assertIn('Unsupported request method', ire.description) + + try: + _, body, s = self.legacy.create_token_response(uri, + body='grant_type=access_token&code=123', http_method=method) + self.fail('This should have failed with InvalidRequestError') + except errors.InvalidRequestError as ire: + self.assertIn('Unsupported request method', ire.description) + + try: + _, body, s = self.backend.create_token_response(uri, + body='grant_type=access_token&code=123', http_method=method) + self.fail('This should have failed with InvalidRequestError') + except errors.InvalidRequestError as ire: + self.assertIn('Unsupported request method', ire.description) + + def test_invalid_post_request(self): + self.validator.authenticate_client.side_effect = self.set_client + for param in ['token', 'secret', 'code', 'foo']: + uri = 'https://i/b/token?' + urlencode([(param, 'secret')]) + try: + _, body, s = self.web.create_token_response(uri, + body='grant_type=access_token&code=123') + self.fail('This should have failed with InvalidRequestError') + except errors.InvalidRequestError as ire: + self.assertIn('URL query parameters are not allowed', ire.description) + + try: + _, body, s = self.legacy.create_token_response(uri, + body='grant_type=access_token&code=123') + self.fail('This should have failed with InvalidRequestError') + except errors.InvalidRequestError as ire: + self.assertIn('URL query parameters are not allowed', ire.description) + + try: + _, body, s = self.backend.create_token_response(uri, + body='grant_type=access_token&code=123') + self.fail('This should have failed with InvalidRequestError') + except errors.InvalidRequestError as ire: + self.assertIn('URL query parameters are not allowed', ire.description) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_extra_credentials.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_extra_credentials.py new file mode 100644 index 0000000000..97aaf86dff --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_extra_credentials.py @@ -0,0 +1,69 @@ +"""Ensure extra credentials can be supplied for inclusion in tokens. +""" +from unittest import mock + +from oauthlib.oauth2 import ( + BackendApplicationServer, LegacyApplicationServer, MobileApplicationServer, + RequestValidator, WebApplicationServer, +) + +from tests.unittest import TestCase + + +class ExtraCredentialsTest(TestCase): + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_default_redirect_uri.return_value = 'https://i.b/cb' + self.web = WebApplicationServer(self.validator) + self.mobile = MobileApplicationServer(self.validator) + self.legacy = LegacyApplicationServer(self.validator) + self.backend = BackendApplicationServer(self.validator) + + def test_post_authorization_request(self): + def save_code(client_id, token, request): + self.assertEqual('creds', request.extra) + + def save_token(token, request): + self.assertEqual('creds', request.extra) + + # Authorization code grant + self.validator.save_authorization_code.side_effect = save_code + self.web.create_authorization_response( + 'https://i.b/auth?client_id=foo&response_type=code', + scopes=['foo'], + credentials={'extra': 'creds'}) + + # Implicit grant + self.validator.save_bearer_token.side_effect = save_token + self.mobile.create_authorization_response( + 'https://i.b/auth?client_id=foo&response_type=token', + scopes=['foo'], + credentials={'extra': 'creds'}) + + def test_token_request(self): + def save_token(token, request): + self.assertIn('extra', token) + + self.validator.save_bearer_token.side_effect = save_token + self.validator.authenticate_client.side_effect = self.set_client + + # Authorization code grant + self.web.create_token_response('https://i.b/token', + body='grant_type=authorization_code&code=foo', + credentials={'extra': 'creds'}) + + # Password credentials grant + self.legacy.create_token_response('https://i.b/token', + body='grant_type=password&username=foo&password=bar', + credentials={'extra': 'creds'}) + + # Client credentials grant + self.backend.create_token_response('https://i.b/token', + body='grant_type=client_credentials', + credentials={'extra': 'creds'}) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py new file mode 100644 index 0000000000..6d3d119a3b --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +from json import loads +from unittest.mock import MagicMock + +from oauthlib.common import urlencode +from oauthlib.oauth2 import IntrospectEndpoint, RequestValidator + +from tests.unittest import TestCase + + +class IntrospectEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.client_authentication_required.return_value = True + self.validator.authenticate_client.return_value = True + self.validator.validate_bearer_token.return_value = True + self.validator.introspect_token.return_value = {} + self.endpoint = IntrospectEndpoint(self.validator) + + self.uri = 'should_not_matter' + self.headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + self.resp_h = { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + 'Pragma': 'no-cache' + } + self.resp_b = { + "active": True + } + + def test_introspect_token(self): + for token_type in ('access_token', 'refresh_token', 'invalid'): + body = urlencode([('token', 'foo'), + ('token_type_hint', token_type)]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), self.resp_b) + self.assertEqual(s, 200) + + def test_introspect_token_nohint(self): + # don't specify token_type_hint + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), self.resp_b) + self.assertEqual(s, 200) + + def test_introspect_token_false(self): + self.validator.introspect_token.return_value = None + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), {"active": False}) + self.assertEqual(s, 200) + + def test_introspect_token_claims(self): + self.validator.introspect_token.return_value = {"foo": "bar"} + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), {"active": True, "foo": "bar"}) + self.assertEqual(s, 200) + + def test_introspect_token_claims_spoof_active(self): + self.validator.introspect_token.return_value = {"foo": "bar", "active": False} + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), {"active": True, "foo": "bar"}) + self.assertEqual(s, 200) + + def test_introspect_token_client_authentication_failed(self): + self.validator.authenticate_client.return_value = False + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + "WWW-Authenticate": 'Bearer error="invalid_client"' + }) + self.assertEqual(loads(b)['error'], 'invalid_client') + self.assertEqual(s, 401) + + def test_introspect_token_public_client_authentication(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = True + for token_type in ('access_token', 'refresh_token', 'invalid'): + body = urlencode([('token', 'foo'), + ('token_type_hint', token_type)]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b), self.resp_b) + self.assertEqual(s, 200) + + def test_introspect_token_public_client_authentication_failed(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = False + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = self.endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + "WWW-Authenticate": 'Bearer error="invalid_client"' + }) + self.assertEqual(loads(b)['error'], 'invalid_client') + self.assertEqual(s, 401) + + def test_introspect_unsupported_token(self): + endpoint = IntrospectEndpoint(self.validator, + supported_token_types=['access_token']) + body = urlencode([('token', 'foo'), + ('token_type_hint', 'refresh_token')]) + h, b, s = endpoint.create_introspect_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'unsupported_token_type') + self.assertEqual(s, 400) + + h, b, s = endpoint.create_introspect_response(self.uri, + headers=self.headers, body='') + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'invalid_request') + self.assertEqual(s, 400) + + def test_introspect_invalid_request_method(self): + endpoint = IntrospectEndpoint(self.validator, + supported_token_types=['access_token']) + test_methods = ['GET', 'pUt', 'dEleTe', 'paTcH'] + test_methods = test_methods + [x.lower() for x in test_methods] + [x.upper() for x in test_methods] + for method in test_methods: + body = urlencode([('token', 'foo'), + ('token_type_hint', 'refresh_token')]) + h, b, s = endpoint.create_introspect_response(self.uri, + http_method = method, headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'invalid_request') + self.assertIn('Unsupported request method', loads(b)['error_description']) + self.assertEqual(s, 400) + + def test_introspect_bad_post_request(self): + endpoint = IntrospectEndpoint(self.validator, + supported_token_types=['access_token']) + for param in ['token', 'secret', 'code', 'foo']: + uri = 'http://some.endpoint?' + urlencode([(param, 'secret')]) + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = endpoint.create_introspect_response( + uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'invalid_request') + self.assertIn('query parameters are not allowed', loads(b)['error_description']) + self.assertEqual(s, 400) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_metadata.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_metadata.py new file mode 100644 index 0000000000..1f5b912100 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +import json + +from oauthlib.oauth2 import MetadataEndpoint, Server, TokenEndpoint + +from tests.unittest import TestCase + + +class MetadataEndpointTest(TestCase): + def setUp(self): + self.metadata = { + "issuer": 'https://foo.bar' + } + + def test_openid_oauth2_preconfigured(self): + default_claims = { + "issuer": 'https://foo.bar', + "authorization_endpoint": "https://foo.bar/authorize", + "revocation_endpoint": "https://foo.bar/revoke", + "introspection_endpoint": "https://foo.bar/introspect", + "token_endpoint": "https://foo.bar/token" + } + from oauthlib.oauth2 import Server as OAuth2Server + from oauthlib.openid import Server as OpenIDServer + + endpoint = OAuth2Server(None) + metadata = MetadataEndpoint([endpoint], default_claims) + oauth2_claims = metadata.claims + + endpoint = OpenIDServer(None) + metadata = MetadataEndpoint([endpoint], default_claims) + openid_claims = metadata.claims + + # Pure OAuth2 Authorization Metadata are similar with OpenID but + # response_type not! (OIDC contains "id_token" and hybrid flows) + del oauth2_claims['response_types_supported'] + del openid_claims['response_types_supported'] + + self.maxDiff = None + self.assertEqual(openid_claims, oauth2_claims) + + def test_create_metadata_response(self): + endpoint = TokenEndpoint(None, None, grant_types={"password": None}) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'https://foo.bar', + "token_endpoint": "https://foo.bar/token" + }) + headers, body, status = metadata.create_metadata_response('/', 'GET') + assert headers == { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + } + claims = json.loads(body) + assert claims['issuer'] == 'https://foo.bar' + + def test_token_endpoint(self): + endpoint = TokenEndpoint(None, None, grant_types={"password": None}) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'https://foo.bar', + "token_endpoint": "https://foo.bar/token" + }) + self.assertIn("grant_types_supported", metadata.claims) + self.assertEqual(metadata.claims["grant_types_supported"], ["password"]) + + def test_token_endpoint_overridden(self): + endpoint = TokenEndpoint(None, None, grant_types={"password": None}) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'https://foo.bar', + "token_endpoint": "https://foo.bar/token", + "grant_types_supported": ["pass_word_special_provider"] + }) + self.assertIn("grant_types_supported", metadata.claims) + self.assertEqual(metadata.claims["grant_types_supported"], ["pass_word_special_provider"]) + + def test_mandatory_fields(self): + metadata = MetadataEndpoint([], self.metadata) + self.assertIn("issuer", metadata.claims) + self.assertEqual(metadata.claims["issuer"], 'https://foo.bar') + + def test_server_metadata(self): + endpoint = Server(None) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'https://foo.bar', + "authorization_endpoint": "https://foo.bar/authorize", + "introspection_endpoint": "https://foo.bar/introspect", + "revocation_endpoint": "https://foo.bar/revoke", + "token_endpoint": "https://foo.bar/token", + "jwks_uri": "https://foo.bar/certs", + "scopes_supported": ["email", "profile"] + }) + expected_claims = { + "issuer": "https://foo.bar", + "authorization_endpoint": "https://foo.bar/authorize", + "introspection_endpoint": "https://foo.bar/introspect", + "revocation_endpoint": "https://foo.bar/revoke", + "token_endpoint": "https://foo.bar/token", + "jwks_uri": "https://foo.bar/certs", + "scopes_supported": ["email", "profile"], + "grant_types_supported": [ + "authorization_code", + "password", + "client_credentials", + "refresh_token", + "implicit" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "response_types_supported": [ + "code", + "token" + ], + "response_modes_supported": [ + "query", + "fragment" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "revocation_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "introspection_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ] + } + + def sort_list(claims): + for k in claims.keys(): + claims[k] = sorted(claims[k]) + + sort_list(metadata.claims) + sort_list(expected_claims) + self.assertEqual(sorted(metadata.claims.items()), sorted(expected_claims.items())) + + def test_metadata_validate_issuer(self): + with self.assertRaises(ValueError): + endpoint = TokenEndpoint( + None, None, grant_types={"password": None}, + ) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'http://foo.bar', + "token_endpoint": "https://foo.bar/token", + }) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py new file mode 100644 index 0000000000..04533888e9 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py @@ -0,0 +1,108 @@ +"""Ensure all tokens are associated with a resource owner. +""" +import json +from unittest import mock + +from oauthlib.oauth2 import ( + BackendApplicationServer, LegacyApplicationServer, MobileApplicationServer, + RequestValidator, WebApplicationServer, +) + +from tests.unittest import TestCase + +from .test_utils import get_fragment_credentials, get_query_credentials + + +class ResourceOwnerAssociationTest(TestCase): + + auth_uri = 'http://example.com/path?client_id=abc' + token_uri = 'http://example.com/path' + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def set_user(self, client_id, code, client, request): + request.user = 'test' + return True + + def set_user_from_username(self, username, password, client, request): + request.user = 'test' + return True + + def set_user_from_credentials(self, request): + request.user = 'test' + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def inspect_client(self, request, refresh_token=False): + if not request.user: + raise ValueError() + return 'abc' + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_default_redirect_uri.return_value = 'http://i.b./path' + self.validator.get_code_challenge.return_value = None + self.validator.authenticate_client.side_effect = self.set_client + self.web = WebApplicationServer(self.validator, + token_generator=self.inspect_client) + self.mobile = MobileApplicationServer(self.validator, + token_generator=self.inspect_client) + self.legacy = LegacyApplicationServer(self.validator, + token_generator=self.inspect_client) + self.backend = BackendApplicationServer(self.validator, + token_generator=self.inspect_client) + + def test_web_application(self): + # TODO: code generator + intercept test + h, _, s = self.web.create_authorization_response( + self.auth_uri + '&response_type=code', + credentials={'user': 'test'}, scopes=['random']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + code = get_query_credentials(h['Location'])['code'][0] + self.assertRaises(ValueError, + self.web.create_token_response, self.token_uri, + body='grant_type=authorization_code&code=%s' % code) + + self.validator.validate_code.side_effect = self.set_user + _, body, _ = self.web.create_token_response(self.token_uri, + body='grant_type=authorization_code&code=%s' % code) + self.assertEqual(json.loads(body)['access_token'], 'abc') + + def test_mobile_application(self): + self.assertRaises(ValueError, + self.mobile.create_authorization_response, + self.auth_uri + '&response_type=token') + + h, _, s = self.mobile.create_authorization_response( + self.auth_uri + '&response_type=token', + credentials={'user': 'test'}, scopes=['random']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertEqual(get_fragment_credentials(h['Location'])['access_token'][0], 'abc') + + def test_legacy_application(self): + body = 'grant_type=password&username=abc&password=secret' + self.assertRaises(ValueError, + self.legacy.create_token_response, + self.token_uri, body=body) + + self.validator.validate_user.side_effect = self.set_user_from_username + _, body, _ = self.legacy.create_token_response( + self.token_uri, body=body) + self.assertEqual(json.loads(body)['access_token'], 'abc') + + def test_backend_application(self): + body = 'grant_type=client_credentials' + self.assertRaises(ValueError, + self.backend.create_token_response, + self.token_uri, body=body) + + self.validator.authenticate_client.side_effect = self.set_user_from_credentials + _, body, _ = self.backend.create_token_response( + self.token_uri, body=body) + self.assertEqual(json.loads(body)['access_token'], 'abc') diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py new file mode 100644 index 0000000000..338dbd91fa --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +from json import loads +from unittest.mock import MagicMock + +from oauthlib.common import urlencode +from oauthlib.oauth2 import RequestValidator, RevocationEndpoint + +from tests.unittest import TestCase + + +class RevocationEndpointTest(TestCase): + + def setUp(self): + self.validator = MagicMock(wraps=RequestValidator()) + self.validator.client_authentication_required.return_value = True + self.validator.authenticate_client.return_value = True + self.validator.revoke_token.return_value = True + self.endpoint = RevocationEndpoint(self.validator) + + self.uri = 'https://example.com/revoke_token' + self.headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + self.resp_h = { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + 'Pragma': 'no-cache' + } + + def test_revoke_token(self): + for token_type in ('access_token', 'refresh_token', 'invalid'): + body = urlencode([('token', 'foo'), + ('token_type_hint', token_type)]) + h, b, s = self.endpoint.create_revocation_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, {}) + self.assertEqual(b, '') + self.assertEqual(s, 200) + + # don't specify token_type_hint + body = urlencode([('token', 'foo')]) + h, b, s = self.endpoint.create_revocation_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, {}) + self.assertEqual(b, '') + self.assertEqual(s, 200) + + def test_revoke_token_client_authentication_failed(self): + self.validator.authenticate_client.return_value = False + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = self.endpoint.create_revocation_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + "WWW-Authenticate": 'Bearer error="invalid_client"' + }) + self.assertEqual(loads(b)['error'], 'invalid_client') + self.assertEqual(s, 401) + + def test_revoke_token_public_client_authentication(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = True + for token_type in ('access_token', 'refresh_token', 'invalid'): + body = urlencode([('token', 'foo'), + ('token_type_hint', token_type)]) + h, b, s = self.endpoint.create_revocation_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, {}) + self.assertEqual(b, '') + self.assertEqual(s, 200) + + def test_revoke_token_public_client_authentication_failed(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = False + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = self.endpoint.create_revocation_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + "WWW-Authenticate": 'Bearer error="invalid_client"' + }) + self.assertEqual(loads(b)['error'], 'invalid_client') + self.assertEqual(s, 401) + + def test_revoke_with_callback(self): + endpoint = RevocationEndpoint(self.validator, enable_jsonp=True) + callback = 'package.hello_world' + for token_type in ('access_token', 'refresh_token', 'invalid'): + body = urlencode([('token', 'foo'), + ('token_type_hint', token_type), + ('callback', callback)]) + h, b, s = endpoint.create_revocation_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, {}) + self.assertEqual(b, callback + '();') + self.assertEqual(s, 200) + + def test_revoke_unsupported_token(self): + endpoint = RevocationEndpoint(self.validator, + supported_token_types=['access_token']) + body = urlencode([('token', 'foo'), + ('token_type_hint', 'refresh_token')]) + h, b, s = endpoint.create_revocation_response(self.uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'unsupported_token_type') + self.assertEqual(s, 400) + + h, b, s = endpoint.create_revocation_response(self.uri, + headers=self.headers, body='') + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'invalid_request') + self.assertEqual(s, 400) + + def test_revoke_invalid_request_method(self): + endpoint = RevocationEndpoint(self.validator, + supported_token_types=['access_token']) + test_methods = ['GET', 'pUt', 'dEleTe', 'paTcH'] + test_methods = test_methods + [x.lower() for x in test_methods] + [x.upper() for x in test_methods] + for method in test_methods: + body = urlencode([('token', 'foo'), + ('token_type_hint', 'refresh_token')]) + h, b, s = endpoint.create_revocation_response(self.uri, + http_method = method, headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'invalid_request') + self.assertIn('Unsupported request method', loads(b)['error_description']) + self.assertEqual(s, 400) + + def test_revoke_bad_post_request(self): + endpoint = RevocationEndpoint(self.validator, + supported_token_types=['access_token']) + for param in ['token', 'secret', 'code', 'foo']: + uri = 'http://some.endpoint?' + urlencode([(param, 'secret')]) + body = urlencode([('token', 'foo'), + ('token_type_hint', 'access_token')]) + h, b, s = endpoint.create_revocation_response(uri, + headers=self.headers, body=body) + self.assertEqual(h, self.resp_h) + self.assertEqual(loads(b)['error'], 'invalid_request') + self.assertIn('query parameters are not allowed', loads(b)['error_description']) + self.assertEqual(s, 400) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_scope_handling.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_scope_handling.py new file mode 100644 index 0000000000..4c87d9c7c8 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_scope_handling.py @@ -0,0 +1,193 @@ +"""Ensure scope is preserved across authorization. + +Fairly trivial in all grants except the Authorization Code Grant where scope +need to be persisted temporarily in an authorization code. +""" +import json +from unittest import mock + +from oauthlib.oauth2 import ( + BackendApplicationServer, LegacyApplicationServer, MobileApplicationServer, + RequestValidator, Server, WebApplicationServer, +) + +from tests.unittest import TestCase + +from .test_utils import get_fragment_credentials, get_query_credentials + + +class TestScopeHandling(TestCase): + + DEFAULT_REDIRECT_URI = 'http://i.b./path' + + def set_scopes(self, scopes): + def set_request_scopes(client_id, code, client, request): + request.scopes = scopes + return True + return set_request_scopes + + def set_user(self, request): + request.user = 'foo' + request.client_id = 'bar' + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_default_redirect_uri.return_value = TestScopeHandling.DEFAULT_REDIRECT_URI + self.validator.get_code_challenge.return_value = None + self.validator.authenticate_client.side_effect = self.set_client + self.server = Server(self.validator) + self.web = WebApplicationServer(self.validator) + self.mobile = MobileApplicationServer(self.validator) + self.legacy = LegacyApplicationServer(self.validator) + self.backend = BackendApplicationServer(self.validator) + + def test_scope_extraction(self): + scopes = ( + ('images', ['images']), + ('images+videos', ['images', 'videos']), + ('images+videos+openid', ['images', 'videos', 'openid']), + ('http%3A%2f%2fa.b%2fvideos', ['http://a.b/videos']), + ('http%3A%2f%2fa.b%2fvideos+pics', ['http://a.b/videos', 'pics']), + ('pics+http%3A%2f%2fa.b%2fvideos', ['pics', 'http://a.b/videos']), + ('http%3A%2f%2fa.b%2fvideos+https%3A%2f%2fc.d%2Fsecret', ['http://a.b/videos', 'https://c.d/secret']), + ) + + uri = 'http://example.com/path?client_id=abc&scope=%s&response_type=%s' + for scope, correct_scopes in scopes: + scopes, _ = self.web.validate_authorization_request( + uri % (scope, 'code')) + self.assertCountEqual(scopes, correct_scopes) + scopes, _ = self.mobile.validate_authorization_request( + uri % (scope, 'token')) + self.assertCountEqual(scopes, correct_scopes) + scopes, _ = self.server.validate_authorization_request( + uri % (scope, 'code')) + self.assertCountEqual(scopes, correct_scopes) + + def test_scope_preservation(self): + scope = 'pics+http%3A%2f%2fa.b%2fvideos' + decoded_scope = 'pics http://a.b/videos' + auth_uri = 'http://example.com/path?client_id=abc&response_type=' + token_uri = 'http://example.com/path' + + # authorization grant + for backend_server_type in ['web', 'server']: + h, _, s = getattr(self, backend_server_type).create_authorization_response( + auth_uri + 'code', scopes=decoded_scope.split(' ')) + self.validator.validate_code.side_effect = self.set_scopes(decoded_scope.split(' ')) + self.assertEqual(s, 302) + self.assertIn('Location', h) + code = get_query_credentials(h['Location'])['code'][0] + _, body, _ = getattr(self, backend_server_type).create_token_response(token_uri, + body='client_id=me&redirect_uri=http://back.to/me&grant_type=authorization_code&code=%s' % code) + self.assertEqual(json.loads(body)['scope'], decoded_scope) + + # implicit grant + for backend_server_type in ['mobile', 'server']: + h, _, s = getattr(self, backend_server_type).create_authorization_response( + auth_uri + 'token', scopes=decoded_scope.split(' ')) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertEqual(get_fragment_credentials(h['Location'])['scope'][0], decoded_scope) + + # resource owner password credentials grant + for backend_server_type in ['legacy', 'server']: + body = 'grant_type=password&username=abc&password=secret&scope=%s' + + _, body, _ = getattr(self, backend_server_type).create_token_response(token_uri, + body=body % scope) + self.assertEqual(json.loads(body)['scope'], decoded_scope) + + # client credentials grant + for backend_server_type in ['backend', 'server']: + body = 'grant_type=client_credentials&scope=%s' + self.validator.authenticate_client.side_effect = self.set_user + _, body, _ = getattr(self, backend_server_type).create_token_response(token_uri, + body=body % scope) + self.assertEqual(json.loads(body)['scope'], decoded_scope) + + def test_scope_changed(self): + scope = 'pics+http%3A%2f%2fa.b%2fvideos' + scopes = ['images', 'http://a.b/videos'] + decoded_scope = 'images http://a.b/videos' + auth_uri = 'http://example.com/path?client_id=abc&response_type=' + token_uri = 'http://example.com/path' + + # authorization grant + h, _, s = self.web.create_authorization_response( + auth_uri + 'code', scopes=scopes) + self.assertEqual(s, 302) + self.assertIn('Location', h) + code = get_query_credentials(h['Location'])['code'][0] + self.validator.validate_code.side_effect = self.set_scopes(scopes) + _, body, _ = self.web.create_token_response(token_uri, + body='grant_type=authorization_code&code=%s' % code) + self.assertEqual(json.loads(body)['scope'], decoded_scope) + + # implicit grant + self.validator.validate_scopes.side_effect = self.set_scopes(scopes) + h, _, s = self.mobile.create_authorization_response( + auth_uri + 'token', scopes=scopes) + self.assertEqual(s, 302) + self.assertIn('Location', h) + self.assertEqual(get_fragment_credentials(h['Location'])['scope'][0], decoded_scope) + + # resource owner password credentials grant + self.validator.validate_scopes.side_effect = self.set_scopes(scopes) + body = 'grant_type=password&username=abc&password=secret&scope=%s' + _, body, _ = self.legacy.create_token_response(token_uri, + body=body % scope) + self.assertEqual(json.loads(body)['scope'], decoded_scope) + + # client credentials grant + self.validator.validate_scopes.side_effect = self.set_scopes(scopes) + self.validator.authenticate_client.side_effect = self.set_user + body = 'grant_type=client_credentials&scope=%s' + _, body, _ = self.backend.create_token_response(token_uri, + body=body % scope) + + self.assertEqual(json.loads(body)['scope'], decoded_scope) + + def test_invalid_scope(self): + scope = 'pics+http%3A%2f%2fa.b%2fvideos' + auth_uri = 'http://example.com/path?client_id=abc&response_type=' + token_uri = 'http://example.com/path' + + self.validator.validate_scopes.return_value = False + + # authorization grant + h, _, s = self.web.create_authorization_response( + auth_uri + 'code', scopes=['invalid']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + error = get_query_credentials(h['Location'])['error'][0] + self.assertEqual(error, 'invalid_scope') + + # implicit grant + h, _, s = self.mobile.create_authorization_response( + auth_uri + 'token', scopes=['invalid']) + self.assertEqual(s, 302) + self.assertIn('Location', h) + error = get_fragment_credentials(h['Location'])['error'][0] + self.assertEqual(error, 'invalid_scope') + + # resource owner password credentials grant + body = 'grant_type=password&username=abc&password=secret&scope=%s' + _, body, _ = self.legacy.create_token_response(token_uri, + body=body % scope) + self.assertEqual(json.loads(body)['error'], 'invalid_scope') + + # client credentials grant + self.validator.authenticate_client.side_effect = self.set_user + body = 'grant_type=client_credentials&scope=%s' + _, body, _ = self.backend.create_token_response(token_uri, + body=body % scope) + self.assertEqual(json.loads(body)['error'], 'invalid_scope') diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_utils.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_utils.py new file mode 100644 index 0000000000..5eae1956f4 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/endpoints/test_utils.py @@ -0,0 +1,11 @@ +import urllib.parse as urlparse + + +def get_query_credentials(uri): + return urlparse.parse_qs(urlparse.urlparse(uri).query, + keep_blank_values=True) + + +def get_fragment_credentials(uri): + return urlparse.parse_qs(urlparse.urlparse(uri).fragment, + keep_blank_values=True) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/__init__.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_authorization_code.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_authorization_code.py new file mode 100644 index 0000000000..77e1a81b46 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_authorization_code.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.grant_types import ( + AuthorizationCodeGrant, authorization_code, +) +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +from tests.unittest import TestCase + + +class AuthorizationCodeGrantTest(TestCase): + + def setUp(self): + self.request = Request('http://a.b/path') + self.request.scopes = ('hello', 'world') + self.request.expires_in = 1800 + self.request.client = 'batman' + self.request.client_id = 'abcdef' + self.request.code = '1234' + self.request.response_type = 'code' + self.request.grant_type = 'authorization_code' + self.request.redirect_uri = 'https://a.b/cb' + + self.mock_validator = mock.MagicMock() + self.mock_validator.is_pkce_required.return_value = False + self.mock_validator.get_code_challenge.return_value = None + self.mock_validator.is_origin_allowed.return_value = False + self.mock_validator.authenticate_client.side_effect = self.set_client + self.auth = AuthorizationCodeGrant(request_validator=self.mock_validator) + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def setup_validators(self): + self.authval1, self.authval2 = mock.Mock(), mock.Mock() + self.authval1.return_value = {} + self.authval2.return_value = {} + self.tknval1, self.tknval2 = mock.Mock(), mock.Mock() + self.tknval1.return_value = None + self.tknval2.return_value = None + self.auth.custom_validators.pre_token.append(self.tknval1) + self.auth.custom_validators.post_token.append(self.tknval2) + self.auth.custom_validators.pre_auth.append(self.authval1) + self.auth.custom_validators.post_auth.append(self.authval2) + + def test_custom_auth_validators(self): + self.setup_validators() + + bearer = BearerToken(self.mock_validator) + self.auth.create_authorization_response(self.request, bearer) + self.assertTrue(self.authval1.called) + self.assertTrue(self.authval2.called) + self.assertFalse(self.tknval1.called) + self.assertFalse(self.tknval2.called) + + def test_custom_token_validators(self): + self.setup_validators() + + bearer = BearerToken(self.mock_validator) + self.auth.create_token_response(self.request, bearer) + self.assertTrue(self.tknval1.called) + self.assertTrue(self.tknval2.called) + self.assertFalse(self.authval1.called) + self.assertFalse(self.authval2.called) + + def test_create_authorization_grant(self): + bearer = BearerToken(self.mock_validator) + self.request.response_mode = 'query' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + grant = dict(Request(h['Location']).uri_query_params) + self.assertIn('code', grant) + self.assertTrue(self.mock_validator.validate_redirect_uri.called) + self.assertTrue(self.mock_validator.validate_response_type.called) + self.assertTrue(self.mock_validator.validate_scopes.called) + + def test_create_authorization_grant_no_scopes(self): + bearer = BearerToken(self.mock_validator) + self.request.response_mode = 'query' + self.request.scopes = [] + self.auth.create_authorization_response(self.request, bearer) + + def test_create_authorization_grant_state(self): + self.request.state = 'abc' + self.request.redirect_uri = None + self.request.response_mode = 'query' + self.mock_validator.get_default_redirect_uri.return_value = 'https://a.b/cb' + bearer = BearerToken(self.mock_validator) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + grant = dict(Request(h['Location']).uri_query_params) + self.assertIn('code', grant) + self.assertIn('state', grant) + self.assertFalse(self.mock_validator.validate_redirect_uri.called) + self.assertTrue(self.mock_validator.get_default_redirect_uri.called) + self.assertTrue(self.mock_validator.validate_response_type.called) + self.assertTrue(self.mock_validator.validate_scopes.called) + + @mock.patch('oauthlib.common.generate_token') + def test_create_authorization_response(self, generate_token): + generate_token.return_value = 'abc' + bearer = BearerToken(self.mock_validator) + self.request.response_mode = 'query' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], 'https://a.b/cb?code=abc') + self.request.response_mode = 'fragment' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], 'https://a.b/cb#code=abc') + + def test_create_token_response(self): + bearer = BearerToken(self.mock_validator) + + h, token, s = self.auth.create_token_response(self.request, bearer) + token = json.loads(token) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('expires_in', token) + self.assertIn('scope', token) + self.assertTrue(self.mock_validator.client_authentication_required.called) + self.assertTrue(self.mock_validator.authenticate_client.called) + self.assertTrue(self.mock_validator.validate_code.called) + self.assertTrue(self.mock_validator.confirm_redirect_uri.called) + self.assertTrue(self.mock_validator.validate_grant_type.called) + self.assertTrue(self.mock_validator.invalidate_authorization_code.called) + + def test_create_token_response_without_refresh_token(self): + self.auth.refresh_token = False # Not to issue refresh token. + + bearer = BearerToken(self.mock_validator) + h, token, s = self.auth.create_token_response(self.request, bearer) + token = json.loads(token) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertNotIn('refresh_token', token) + self.assertIn('expires_in', token) + self.assertIn('scope', token) + self.assertTrue(self.mock_validator.client_authentication_required.called) + self.assertTrue(self.mock_validator.authenticate_client.called) + self.assertTrue(self.mock_validator.validate_code.called) + self.assertTrue(self.mock_validator.confirm_redirect_uri.called) + self.assertTrue(self.mock_validator.validate_grant_type.called) + self.assertTrue(self.mock_validator.invalidate_authorization_code.called) + + def test_invalid_request(self): + del self.request.code + self.assertRaises(errors.InvalidRequestError, self.auth.validate_token_request, + self.request) + + def test_invalid_request_duplicates(self): + request = mock.MagicMock(wraps=self.request) + request.grant_type = 'authorization_code' + request.duplicate_params = ['client_id'] + self.assertRaises(errors.InvalidRequestError, self.auth.validate_token_request, + request) + + def test_authentication_required(self): + """ + ensure client_authentication_required() is properly called + """ + self.auth.validate_token_request(self.request) + self.mock_validator.client_authentication_required.assert_called_once_with(self.request) + + def test_authenticate_client(self): + self.mock_validator.authenticate_client.side_effect = None + self.mock_validator.authenticate_client.return_value = False + self.assertRaises(errors.InvalidClientError, self.auth.validate_token_request, + self.request) + + def test_client_id_missing(self): + self.mock_validator.authenticate_client.side_effect = None + request = mock.MagicMock(wraps=self.request) + request.grant_type = 'authorization_code' + del request.client.client_id + self.assertRaises(NotImplementedError, self.auth.validate_token_request, + request) + + def test_invalid_grant(self): + self.request.client = 'batman' + self.mock_validator.authenticate_client = self.set_client + self.mock_validator.validate_code.return_value = False + self.assertRaises(errors.InvalidGrantError, + self.auth.validate_token_request, self.request) + + def test_invalid_grant_type(self): + self.request.grant_type = 'foo' + self.assertRaises(errors.UnsupportedGrantTypeError, + self.auth.validate_token_request, self.request) + + def test_authenticate_client_id(self): + self.mock_validator.client_authentication_required.return_value = False + self.mock_validator.authenticate_client_id.return_value = False + self.request.state = 'abc' + self.assertRaises(errors.InvalidClientError, + self.auth.validate_token_request, self.request) + + def test_invalid_redirect_uri(self): + self.mock_validator.confirm_redirect_uri.return_value = False + self.assertRaises(errors.MismatchingRedirectURIError, + self.auth.validate_token_request, self.request) + + # PKCE validate_authorization_request + def test_pkce_challenge_missing(self): + self.mock_validator.is_pkce_required.return_value = True + self.assertRaises(errors.MissingCodeChallengeError, + self.auth.validate_authorization_request, self.request) + + def test_pkce_default_method(self): + for required in [True, False]: + self.mock_validator.is_pkce_required.return_value = required + self.request.code_challenge = "present" + _, ri = self.auth.validate_authorization_request(self.request) + self.assertIn("code_challenge", ri) + self.assertIn("code_challenge_method", ri) + self.assertEqual(ri["code_challenge"], "present") + self.assertEqual(ri["code_challenge_method"], "plain") + + def test_pkce_wrong_method(self): + for required in [True, False]: + self.mock_validator.is_pkce_required.return_value = required + self.request.code_challenge = "present" + self.request.code_challenge_method = "foobar" + self.assertRaises(errors.UnsupportedCodeChallengeMethodError, + self.auth.validate_authorization_request, self.request) + + # PKCE validate_token_request + def test_pkce_verifier_missing(self): + self.mock_validator.is_pkce_required.return_value = True + self.assertRaises(errors.MissingCodeVerifierError, + self.auth.validate_token_request, self.request) + + # PKCE validate_token_request + def test_pkce_required_verifier_missing_challenge_missing(self): + self.mock_validator.is_pkce_required.return_value = True + self.request.code_verifier = None + self.mock_validator.get_code_challenge.return_value = None + self.assertRaises(errors.MissingCodeVerifierError, + self.auth.validate_token_request, self.request) + + def test_pkce_required_verifier_missing_challenge_valid(self): + self.mock_validator.is_pkce_required.return_value = True + self.request.code_verifier = None + self.mock_validator.get_code_challenge.return_value = "foo" + self.assertRaises(errors.MissingCodeVerifierError, + self.auth.validate_token_request, self.request) + + def test_pkce_required_verifier_valid_challenge_missing(self): + self.mock_validator.is_pkce_required.return_value = True + self.request.code_verifier = "foobar" + self.mock_validator.get_code_challenge.return_value = None + self.assertRaises(errors.InvalidGrantError, + self.auth.validate_token_request, self.request) + + def test_pkce_required_verifier_valid_challenge_valid_method_valid(self): + self.mock_validator.is_pkce_required.return_value = True + self.request.code_verifier = "foobar" + self.mock_validator.get_code_challenge.return_value = "foobar" + self.mock_validator.get_code_challenge_method.return_value = "plain" + self.auth.validate_token_request(self.request) + + def test_pkce_required_verifier_invalid_challenge_valid_method_valid(self): + self.mock_validator.is_pkce_required.return_value = True + self.request.code_verifier = "foobar" + self.mock_validator.get_code_challenge.return_value = "raboof" + self.mock_validator.get_code_challenge_method.return_value = "plain" + self.assertRaises(errors.InvalidGrantError, + self.auth.validate_token_request, self.request) + + def test_pkce_required_verifier_valid_challenge_valid_method_wrong(self): + self.mock_validator.is_pkce_required.return_value = True + self.request.code_verifier = "present" + self.mock_validator.get_code_challenge.return_value = "foobar" + self.mock_validator.get_code_challenge_method.return_value = "cryptic_method" + self.assertRaises(errors.ServerError, + self.auth.validate_token_request, self.request) + + def test_pkce_verifier_valid_challenge_valid_method_missing(self): + self.mock_validator.is_pkce_required.return_value = True + self.request.code_verifier = "present" + self.mock_validator.get_code_challenge.return_value = "foobar" + self.mock_validator.get_code_challenge_method.return_value = None + self.assertRaises(errors.InvalidGrantError, + self.auth.validate_token_request, self.request) + + def test_pkce_optional_verifier_valid_challenge_missing(self): + self.mock_validator.is_pkce_required.return_value = False + self.request.code_verifier = "present" + self.mock_validator.get_code_challenge.return_value = None + self.auth.validate_token_request(self.request) + + def test_pkce_optional_verifier_missing_challenge_valid(self): + self.mock_validator.is_pkce_required.return_value = False + self.request.code_verifier = None + self.mock_validator.get_code_challenge.return_value = "foobar" + self.assertRaises(errors.MissingCodeVerifierError, + self.auth.validate_token_request, self.request) + + # PKCE functions + def test_wrong_code_challenge_method_plain(self): + self.assertFalse(authorization_code.code_challenge_method_plain("foo", "bar")) + + def test_correct_code_challenge_method_plain(self): + self.assertTrue(authorization_code.code_challenge_method_plain("foo", "foo")) + + def test_wrong_code_challenge_method_s256(self): + self.assertFalse(authorization_code.code_challenge_method_s256("foo", "bar")) + + def test_correct_code_challenge_method_s256(self): + # "abcd" as verifier gives a '+' to base64 + self.assertTrue( + authorization_code.code_challenge_method_s256("abcd", + "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o-Fhk28DFYk") + ) + # "/" as verifier gives a '/' and '+' to base64 + self.assertTrue( + authorization_code.code_challenge_method_s256("/", + "il7asoJjJEMhngUeSt4tHVu8Zxx4EFG_FDeJfL3-oPE") + ) + # Example from PKCE RFCE + self.assertTrue( + authorization_code.code_challenge_method_s256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + ) + + def test_code_modifier_called(self): + bearer = BearerToken(self.mock_validator) + code_modifier = mock.MagicMock(wraps=lambda grant, *a: grant) + self.auth.register_code_modifier(code_modifier) + self.auth.create_authorization_response(self.request, bearer) + code_modifier.assert_called_once() + + def test_hybrid_token_save(self): + bearer = BearerToken(self.mock_validator) + self.auth.register_code_modifier( + lambda grant, *a: dict(list(grant.items()) + [('access_token', 1)]) + ) + self.auth.create_authorization_response(self.request, bearer) + self.mock_validator.save_token.assert_called_once() + + # CORS + + def test_create_cors_headers(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = True + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertEqual( + headers['Access-Control-Allow-Origin'], 'https://foo.bar' + ) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) + + def test_create_cors_headers_no_origin(self): + bearer = BearerToken(self.mock_validator) + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_insecure_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'http://foo.bar' + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_invalid_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = False + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_client_credentials.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_client_credentials.py new file mode 100644 index 0000000000..e9559c7931 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_client_credentials.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749.grant_types import ClientCredentialsGrant +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +from tests.unittest import TestCase + + +class ClientCredentialsGrantTest(TestCase): + + def setUp(self): + mock_client = mock.MagicMock() + mock_client.user.return_value = 'mocked user' + self.request = Request('http://a.b/path') + self.request.grant_type = 'client_credentials' + self.request.client = mock_client + self.request.scopes = ('mocked', 'scopes') + self.mock_validator = mock.MagicMock() + self.auth = ClientCredentialsGrant( + request_validator=self.mock_validator) + + def test_custom_auth_validators_unsupported(self): + authval1, authval2 = mock.Mock(), mock.Mock() + expected = ('ClientCredentialsGrant does not support authorization ' + 'validators. Use token validators instead.') + with self.assertRaises(ValueError) as caught: + ClientCredentialsGrant(self.mock_validator, pre_auth=[authval1]) + self.assertEqual(caught.exception.args[0], expected) + with self.assertRaises(ValueError) as caught: + ClientCredentialsGrant(self.mock_validator, post_auth=[authval2]) + self.assertEqual(caught.exception.args[0], expected) + with self.assertRaises(AttributeError): + self.auth.custom_validators.pre_auth.append(authval1) + with self.assertRaises(AttributeError): + self.auth.custom_validators.pre_auth.append(authval2) + + def test_custom_token_validators(self): + tknval1, tknval2 = mock.Mock(), mock.Mock() + self.auth.custom_validators.pre_token.append(tknval1) + self.auth.custom_validators.post_token.append(tknval2) + + bearer = BearerToken(self.mock_validator) + self.auth.create_token_response(self.request, bearer) + self.assertTrue(tknval1.called) + self.assertTrue(tknval2.called) + + def test_create_token_response(self): + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertIn('Content-Type', headers) + self.assertEqual(headers['Content-Type'], 'application/json') + + def test_error_response(self): + bearer = BearerToken(self.mock_validator) + self.mock_validator.authenticate_client.return_value = False + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + error_msg = json.loads(body) + self.assertIn('error', error_msg) + self.assertEqual(error_msg['error'], 'invalid_client') + self.assertIn('Content-Type', headers) + self.assertEqual(headers['Content-Type'], 'application/json') + + def test_validate_token_response(self): + # wrong grant type, scope + pass diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_implicit.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_implicit.py new file mode 100644 index 0000000000..1fb71a1dc9 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_implicit.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749.grant_types import ImplicitGrant +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +from tests.unittest import TestCase + + +class ImplicitGrantTest(TestCase): + + def setUp(self): + mock_client = mock.MagicMock() + mock_client.user.return_value = 'mocked user' + self.request = Request('http://a.b/path') + self.request.scopes = ('hello', 'world') + self.request.client = mock_client + self.request.client_id = 'abcdef' + self.request.response_type = 'token' + self.request.state = 'xyz' + self.request.redirect_uri = 'https://b.c/p' + + self.mock_validator = mock.MagicMock() + self.auth = ImplicitGrant(request_validator=self.mock_validator) + + @mock.patch('oauthlib.common.generate_token') + def test_create_token_response(self, generate_token): + generate_token.return_value = '1234' + bearer = BearerToken(self.mock_validator, expires_in=1800) + h, b, s = self.auth.create_token_response(self.request, bearer) + correct_uri = 'https://b.c/p#access_token=1234&token_type=Bearer&expires_in=1800&state=xyz&scope=hello+world' + self.assertEqual(s, 302) + self.assertURLEqual(h['Location'], correct_uri, parse_fragment=True) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + + correct_uri = 'https://b.c/p?access_token=1234&token_type=Bearer&expires_in=1800&state=xyz&scope=hello+world' + self.request.response_mode = 'query' + h, b, s = self.auth.create_token_response(self.request, bearer) + self.assertURLEqual(h['Location'], correct_uri) + + def test_custom_validators(self): + self.authval1, self.authval2 = mock.Mock(), mock.Mock() + self.tknval1, self.tknval2 = mock.Mock(), mock.Mock() + for val in (self.authval1, self.authval2): + val.return_value = {} + for val in (self.tknval1, self.tknval2): + val.return_value = None + self.auth.custom_validators.pre_token.append(self.tknval1) + self.auth.custom_validators.post_token.append(self.tknval2) + self.auth.custom_validators.pre_auth.append(self.authval1) + self.auth.custom_validators.post_auth.append(self.authval2) + + bearer = BearerToken(self.mock_validator) + self.auth.create_token_response(self.request, bearer) + self.assertTrue(self.tknval1.called) + self.assertTrue(self.tknval2.called) + self.assertTrue(self.authval1.called) + self.assertTrue(self.authval2.called) + + def test_error_response(self): + pass diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_refresh_token.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_refresh_token.py new file mode 100644 index 0000000000..581f2a4d6a --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_refresh_token.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.grant_types import RefreshTokenGrant +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +from tests.unittest import TestCase + + +class RefreshTokenGrantTest(TestCase): + + def setUp(self): + mock_client = mock.MagicMock() + mock_client.user.return_value = 'mocked user' + self.request = Request('http://a.b/path') + self.request.grant_type = 'refresh_token' + self.request.refresh_token = 'lsdkfhj230' + self.request.client_id = 'abcdef' + self.request.client = mock_client + self.request.scope = 'foo' + self.mock_validator = mock.MagicMock() + self.auth = RefreshTokenGrant( + request_validator=self.mock_validator) + + def test_create_token_response(self): + self.mock_validator.get_original_scopes.return_value = ['foo', 'bar'] + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertEqual(token['scope'], 'foo') + + def test_custom_auth_validators_unsupported(self): + authval1, authval2 = mock.Mock(), mock.Mock() + expected = ('RefreshTokenGrant does not support authorization ' + 'validators. Use token validators instead.') + with self.assertRaises(ValueError) as caught: + RefreshTokenGrant(self.mock_validator, pre_auth=[authval1]) + self.assertEqual(caught.exception.args[0], expected) + with self.assertRaises(ValueError) as caught: + RefreshTokenGrant(self.mock_validator, post_auth=[authval2]) + self.assertEqual(caught.exception.args[0], expected) + with self.assertRaises(AttributeError): + self.auth.custom_validators.pre_auth.append(authval1) + with self.assertRaises(AttributeError): + self.auth.custom_validators.pre_auth.append(authval2) + + def test_custom_token_validators(self): + tknval1, tknval2 = mock.Mock(), mock.Mock() + self.auth.custom_validators.pre_token.append(tknval1) + self.auth.custom_validators.post_token.append(tknval2) + + bearer = BearerToken(self.mock_validator) + self.auth.create_token_response(self.request, bearer) + self.assertTrue(tknval1.called) + self.assertTrue(tknval2.called) + + def test_create_token_inherit_scope(self): + self.request.scope = None + self.mock_validator.get_original_scopes.return_value = ['foo', 'bar'] + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertEqual(token['scope'], 'foo bar') + + def test_create_token_within_original_scope(self): + self.mock_validator.get_original_scopes.return_value = ['baz'] + self.mock_validator.is_within_original_scope.return_value = True + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertEqual(token['scope'], 'foo') + + def test_invalid_scope(self): + self.mock_validator.get_original_scopes.return_value = ['baz'] + self.mock_validator.is_within_original_scope.return_value = False + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + self.assertEqual(token['error'], 'invalid_scope') + self.assertEqual(status_code, 400) + + def test_invalid_token(self): + self.mock_validator.validate_refresh_token.return_value = False + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + self.assertEqual(token['error'], 'invalid_grant') + self.assertEqual(status_code, 400) + + def test_invalid_client(self): + self.mock_validator.authenticate_client.return_value = False + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + self.assertEqual(token['error'], 'invalid_client') + self.assertEqual(status_code, 401) + + def test_authentication_required(self): + """ + ensure client_authentication_required() is properly called + """ + self.mock_validator.authenticate_client.return_value = False + self.mock_validator.authenticate_client_id.return_value = False + self.request.code = 'waffles' + self.assertRaises(errors.InvalidClientError, self.auth.validate_token_request, + self.request) + self.mock_validator.client_authentication_required.assert_called_once_with(self.request) + + def test_invalid_grant_type(self): + self.request.grant_type = 'wrong_type' + self.assertRaises(errors.UnsupportedGrantTypeError, + self.auth.validate_token_request, self.request) + + def test_authenticate_client_id(self): + self.mock_validator.client_authentication_required.return_value = False + self.request.refresh_token = mock.MagicMock() + self.mock_validator.authenticate_client_id.return_value = False + self.assertRaises(errors.InvalidClientError, + self.auth.validate_token_request, self.request) + + def test_invalid_refresh_token(self): + # invalid refresh token + self.mock_validator.authenticate_client_id.return_value = True + self.mock_validator.validate_refresh_token.return_value = False + self.assertRaises(errors.InvalidGrantError, + self.auth.validate_token_request, self.request) + # no token provided + del self.request.refresh_token + self.assertRaises(errors.InvalidRequestError, + self.auth.validate_token_request, self.request) + + def test_invalid_scope_original_scopes_empty(self): + self.mock_validator.validate_refresh_token.return_value = True + self.mock_validator.is_within_original_scope.return_value = False + self.assertRaises(errors.InvalidScopeError, + self.auth.validate_token_request, self.request) + + def test_valid_token_request(self): + self.request.scope = 'foo bar' + self.mock_validator.get_original_scopes = mock.Mock() + self.mock_validator.get_original_scopes.return_value = 'foo bar baz' + self.auth.validate_token_request(self.request) + self.assertEqual(self.request.scopes, self.request.scope.split()) + # all ok but without request.scope + del self.request.scope + self.auth.validate_token_request(self.request) + self.assertEqual(self.request.scopes, 'foo bar baz'.split()) + + # CORS + + def test_create_cors_headers(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = True + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertEqual( + headers['Access-Control-Allow-Origin'], 'https://foo.bar' + ) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) + + def test_create_cors_headers_no_origin(self): + bearer = BearerToken(self.mock_validator) + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_insecure_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'http://foo.bar' + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_invalid_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = False + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_resource_owner_password.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_resource_owner_password.py new file mode 100644 index 0000000000..294e27be35 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/grant_types/test_resource_owner_password.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.grant_types import ( + ResourceOwnerPasswordCredentialsGrant, +) +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +from tests.unittest import TestCase + + +class ResourceOwnerPasswordCredentialsGrantTest(TestCase): + + def setUp(self): + mock_client = mock.MagicMock() + mock_client.user.return_value = 'mocked user' + self.request = Request('http://a.b/path') + self.request.grant_type = 'password' + self.request.username = 'john' + self.request.password = 'doe' + self.request.client = mock_client + self.request.scopes = ('mocked', 'scopes') + self.mock_validator = mock.MagicMock() + self.auth = ResourceOwnerPasswordCredentialsGrant( + request_validator=self.mock_validator) + + def set_client(self, request, *args, **kwargs): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def test_create_token_response(self): + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertIn('refresh_token', token) + # ensure client_authentication_required() is properly called + self.mock_validator.client_authentication_required.assert_called_once_with(self.request) + # fail client authentication + self.mock_validator.reset_mock() + self.mock_validator.validate_user.return_value = True + self.mock_validator.authenticate_client.return_value = False + status_code = self.auth.create_token_response(self.request, bearer)[2] + self.assertEqual(status_code, 401) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + + # mock client_authentication_required() returning False then fail + self.mock_validator.reset_mock() + self.mock_validator.client_authentication_required.return_value = False + self.mock_validator.authenticate_client_id.return_value = False + status_code = self.auth.create_token_response(self.request, bearer)[2] + self.assertEqual(status_code, 401) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + + def test_create_token_response_without_refresh_token(self): + # self.auth.refresh_token = False so we don't generate a refresh token + self.auth = ResourceOwnerPasswordCredentialsGrant( + request_validator=self.mock_validator, refresh_token=False) + bearer = BearerToken(self.mock_validator) + headers, body, status_code = self.auth.create_token_response( + self.request, bearer) + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + # ensure no refresh token is generated + self.assertNotIn('refresh_token', token) + # ensure client_authentication_required() is properly called + self.mock_validator.client_authentication_required.assert_called_once_with(self.request) + # fail client authentication + self.mock_validator.reset_mock() + self.mock_validator.validate_user.return_value = True + self.mock_validator.authenticate_client.return_value = False + status_code = self.auth.create_token_response(self.request, bearer)[2] + self.assertEqual(status_code, 401) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + # mock client_authentication_required() returning False then fail + self.mock_validator.reset_mock() + self.mock_validator.client_authentication_required.return_value = False + self.mock_validator.authenticate_client_id.return_value = False + status_code = self.auth.create_token_response(self.request, bearer)[2] + self.assertEqual(status_code, 401) + self.assertEqual(self.mock_validator.save_token.call_count, 0) + + def test_custom_auth_validators_unsupported(self): + authval1, authval2 = mock.Mock(), mock.Mock() + expected = ('ResourceOwnerPasswordCredentialsGrant does not ' + 'support authorization validators. Use token ' + 'validators instead.') + with self.assertRaises(ValueError) as caught: + ResourceOwnerPasswordCredentialsGrant(self.mock_validator, + pre_auth=[authval1]) + self.assertEqual(caught.exception.args[0], expected) + with self.assertRaises(ValueError) as caught: + ResourceOwnerPasswordCredentialsGrant(self.mock_validator, + post_auth=[authval2]) + self.assertEqual(caught.exception.args[0], expected) + with self.assertRaises(AttributeError): + self.auth.custom_validators.pre_auth.append(authval1) + with self.assertRaises(AttributeError): + self.auth.custom_validators.pre_auth.append(authval2) + + def test_custom_token_validators(self): + tknval1, tknval2 = mock.Mock(), mock.Mock() + self.auth.custom_validators.pre_token.append(tknval1) + self.auth.custom_validators.post_token.append(tknval2) + + bearer = BearerToken(self.mock_validator) + self.auth.create_token_response(self.request, bearer) + self.assertTrue(tknval1.called) + self.assertTrue(tknval2.called) + + def test_error_response(self): + pass + + def test_scopes(self): + pass + + def test_invalid_request_missing_params(self): + del self.request.grant_type + self.assertRaises(errors.InvalidRequestError, self.auth.validate_token_request, + self.request) + + def test_invalid_request_duplicates(self): + request = mock.MagicMock(wraps=self.request) + request.duplicate_params = ['scope'] + self.assertRaises(errors.InvalidRequestError, self.auth.validate_token_request, + request) + + def test_invalid_grant_type(self): + self.request.grant_type = 'foo' + self.assertRaises(errors.UnsupportedGrantTypeError, + self.auth.validate_token_request, self.request) + + def test_invalid_user(self): + self.mock_validator.validate_user.return_value = False + self.assertRaises(errors.InvalidGrantError, self.auth.validate_token_request, + self.request) + + def test_client_id_missing(self): + del self.request.client.client_id + self.assertRaises(NotImplementedError, self.auth.validate_token_request, + self.request) + + def test_valid_token_request(self): + self.mock_validator.validate_grant_type.return_value = True + self.auth.validate_token_request(self.request) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/test_parameters.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_parameters.py new file mode 100644 index 0000000000..cd8c9e952b --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_parameters.py @@ -0,0 +1,304 @@ +from unittest.mock import patch + +from oauthlib import signals +from oauthlib.oauth2.rfc6749.errors import * +from oauthlib.oauth2.rfc6749.parameters import * + +from tests.unittest import TestCase + + +@patch('time.time', new=lambda: 1000) +class ParameterTests(TestCase): + + state = 'xyz' + auth_base = { + 'uri': 'https://server.example.com/authorize', + 'client_id': 's6BhdRkqt3', + 'redirect_uri': 'https://client.example.com/cb', + 'state': state, + 'scope': 'photos' + } + list_scope = ['list', 'of', 'scopes'] + + auth_grant = {'response_type': 'code'} + auth_grant_pkce = {'response_type': 'code', 'code_challenge': "code_challenge", + 'code_challenge_method': 'code_challenge_method'} + auth_grant_list_scope = {} + auth_implicit = {'response_type': 'token', 'extra': 'extra'} + auth_implicit_list_scope = {} + + def setUp(self): + self.auth_grant.update(self.auth_base) + self.auth_grant_pkce.update(self.auth_base) + self.auth_implicit.update(self.auth_base) + self.auth_grant_list_scope.update(self.auth_grant) + self.auth_grant_list_scope['scope'] = self.list_scope + self.auth_implicit_list_scope.update(self.auth_implicit) + self.auth_implicit_list_scope['scope'] = self.list_scope + + auth_base_uri = ('https://server.example.com/authorize?response_type={0}' + '&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2F' + 'client.example.com%2Fcb&scope={1}&state={2}{3}') + + auth_base_uri_pkce = ('https://server.example.com/authorize?response_type={0}' + '&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2F' + 'client.example.com%2Fcb&scope={1}&state={2}{3}&code_challenge={4}' + '&code_challenge_method={5}') + + auth_grant_uri = auth_base_uri.format('code', 'photos', state, '') + auth_grant_uri_pkce = auth_base_uri_pkce.format('code', 'photos', state, '', 'code_challenge', + 'code_challenge_method') + auth_grant_uri_list_scope = auth_base_uri.format('code', 'list+of+scopes', state, '') + auth_implicit_uri = auth_base_uri.format('token', 'photos', state, '&extra=extra') + auth_implicit_uri_list_scope = auth_base_uri.format('token', 'list+of+scopes', state, '&extra=extra') + + grant_body = { + 'grant_type': 'authorization_code', + 'code': 'SplxlOBeZQQYbYS6WxSbIA', + 'redirect_uri': 'https://client.example.com/cb' + } + grant_body_pkce = { + 'grant_type': 'authorization_code', + 'code': 'SplxlOBeZQQYbYS6WxSbIA', + 'redirect_uri': 'https://client.example.com/cb', + 'code_verifier': 'code_verifier' + } + grant_body_scope = {'scope': 'photos'} + grant_body_list_scope = {'scope': list_scope} + auth_grant_body = ('grant_type=authorization_code&' + 'code=SplxlOBeZQQYbYS6WxSbIA&' + 'redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb') + auth_grant_body_pkce = ('grant_type=authorization_code&' + 'code=SplxlOBeZQQYbYS6WxSbIA&' + 'redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb' + '&code_verifier=code_verifier') + auth_grant_body_scope = auth_grant_body + '&scope=photos' + auth_grant_body_list_scope = auth_grant_body + '&scope=list+of+scopes' + + pwd_body = { + 'grant_type': 'password', + 'username': 'johndoe', + 'password': 'A3ddj3w' + } + password_body = 'grant_type=password&username=johndoe&password=A3ddj3w' + + cred_grant = {'grant_type': 'client_credentials'} + cred_body = 'grant_type=client_credentials' + + grant_response = 'https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz' + grant_dict = {'code': 'SplxlOBeZQQYbYS6WxSbIA', 'state': state} + + error_nocode = 'https://client.example.com/cb?state=xyz' + error_nostate = 'https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA' + error_wrongstate = 'https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=abc' + error_denied = 'https://client.example.com/cb?error=access_denied&state=xyz' + error_invalid = 'https://client.example.com/cb?error=invalid_request&state=xyz' + + implicit_base = 'https://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA&scope=abc&' + implicit_response = implicit_base + 'state={}&token_type=example&expires_in=3600'.format(state) + implicit_notype = implicit_base + 'state={}&expires_in=3600'.format(state) + implicit_wrongstate = implicit_base + 'state={}&token_type=exampleexpires_in=3600'.format('invalid') + implicit_nostate = implicit_base + 'token_type=example&expires_in=3600' + implicit_notoken = 'https://example.com/cb#state=xyz&token_type=example&expires_in=3600' + + implicit_dict = { + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'state': state, + 'token_type': 'example', + 'expires_in': 3600, + 'expires_at': 4600, + 'scope': ['abc'] + } + + json_response = ('{ "access_token": "2YotnFZFEjr1zCsicMWpAA",' + ' "token_type": "example",' + ' "expires_in": 3600,' + ' "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter": "example_value",' + ' "scope":"abc def"}') + json_response_noscope = ('{ "access_token": "2YotnFZFEjr1zCsicMWpAA",' + ' "token_type": "example",' + ' "expires_in": 3600,' + ' "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter": "example_value" }') + json_response_noexpire = ('{ "access_token": "2YotnFZFEjr1zCsicMWpAA",' + ' "token_type": "example",' + ' "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter": "example_value"}') + json_response_expirenull = ('{ "access_token": "2YotnFZFEjr1zCsicMWpAA",' + ' "token_type": "example",' + ' "expires_in": null,' + ' "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter": "example_value"}') + + json_custom_error = '{ "error": "incorrect_client_credentials" }' + json_error = '{ "error": "access_denied" }' + + json_notoken = ('{ "token_type": "example",' + ' "expires_in": 3600,' + ' "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter": "example_value" }') + + json_notype = ('{ "access_token": "2YotnFZFEjr1zCsicMWpAA",' + ' "expires_in": 3600,' + ' "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",' + ' "example_parameter": "example_value" }') + + json_dict = { + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'token_type': 'example', + 'expires_in': 3600, + 'expires_at': 4600, + 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', + 'example_parameter': 'example_value', + 'scope': ['abc', 'def'] + } + + json_noscope_dict = { + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'token_type': 'example', + 'expires_in': 3600, + 'expires_at': 4600, + 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', + 'example_parameter': 'example_value' + } + + json_noexpire_dict = { + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'token_type': 'example', + 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', + 'example_parameter': 'example_value' + } + + json_notype_dict = { + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'expires_in': 3600, + 'expires_at': 4600, + 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', + 'example_parameter': 'example_value', + } + + url_encoded_response = ('access_token=2YotnFZFEjr1zCsicMWpAA' + '&token_type=example' + '&expires_in=3600' + '&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA' + '&example_parameter=example_value' + '&scope=abc def') + + url_encoded_error = 'error=access_denied' + + url_encoded_notoken = ('token_type=example' + '&expires_in=3600' + '&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA' + '&example_parameter=example_value') + + + def test_prepare_grant_uri(self): + """Verify correct authorization URI construction.""" + self.assertURLEqual(prepare_grant_uri(**self.auth_grant), self.auth_grant_uri) + self.assertURLEqual(prepare_grant_uri(**self.auth_grant_list_scope), self.auth_grant_uri_list_scope) + self.assertURLEqual(prepare_grant_uri(**self.auth_implicit), self.auth_implicit_uri) + self.assertURLEqual(prepare_grant_uri(**self.auth_implicit_list_scope), self.auth_implicit_uri_list_scope) + self.assertURLEqual(prepare_grant_uri(**self.auth_grant_pkce), self.auth_grant_uri_pkce) + + def test_prepare_token_request(self): + """Verify correct access token request body construction.""" + self.assertFormBodyEqual(prepare_token_request(**self.grant_body), self.auth_grant_body) + self.assertFormBodyEqual(prepare_token_request(**self.pwd_body), self.password_body) + self.assertFormBodyEqual(prepare_token_request(**self.cred_grant), self.cred_body) + self.assertFormBodyEqual(prepare_token_request(**self.grant_body_pkce), self.auth_grant_body_pkce) + + def test_grant_response(self): + """Verify correct parameter parsing and validation for auth code responses.""" + params = parse_authorization_code_response(self.grant_response) + self.assertEqual(params, self.grant_dict) + params = parse_authorization_code_response(self.grant_response, state=self.state) + self.assertEqual(params, self.grant_dict) + + self.assertRaises(MissingCodeError, parse_authorization_code_response, + self.error_nocode) + self.assertRaises(AccessDeniedError, parse_authorization_code_response, + self.error_denied) + self.assertRaises(InvalidRequestFatalError, parse_authorization_code_response, + self.error_invalid) + self.assertRaises(MismatchingStateError, parse_authorization_code_response, + self.error_nostate, state=self.state) + self.assertRaises(MismatchingStateError, parse_authorization_code_response, + self.error_wrongstate, state=self.state) + + def test_implicit_token_response(self): + """Verify correct parameter parsing and validation for implicit responses.""" + self.assertEqual(parse_implicit_response(self.implicit_response), + self.implicit_dict) + self.assertRaises(MissingTokenError, parse_implicit_response, + self.implicit_notoken) + self.assertRaises(ValueError, parse_implicit_response, + self.implicit_nostate, state=self.state) + self.assertRaises(ValueError, parse_implicit_response, + self.implicit_wrongstate, state=self.state) + + def test_custom_json_error(self): + self.assertRaises(CustomOAuth2Error, parse_token_response, self.json_custom_error) + + def test_json_token_response(self): + """Verify correct parameter parsing and validation for token responses. """ + self.assertEqual(parse_token_response(self.json_response), self.json_dict) + self.assertRaises(AccessDeniedError, parse_token_response, self.json_error) + self.assertRaises(MissingTokenError, parse_token_response, self.json_notoken) + + self.assertEqual(parse_token_response(self.json_response_noscope, + scope=['all', 'the', 'scopes']), self.json_noscope_dict) + self.assertEqual(parse_token_response(self.json_response_noexpire), self.json_noexpire_dict) + self.assertEqual(parse_token_response(self.json_response_expirenull), self.json_noexpire_dict) + + scope_changes_recorded = [] + def record_scope_change(sender, message, old, new): + scope_changes_recorded.append((message, old, new)) + + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' + signals.scope_changed.connect(record_scope_change) + try: + parse_token_response(self.json_response, scope='aaa') + self.assertEqual(len(scope_changes_recorded), 1) + message, old, new = scope_changes_recorded[0] + for scope in new + old: + self.assertIn(scope, message) + self.assertEqual(old, ['aaa']) + self.assertEqual(set(new), {'abc', 'def'}) + finally: + signals.scope_changed.disconnect(record_scope_change) + del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] + + + def test_json_token_notype(self): + """Verify strict token type parsing only when configured. """ + self.assertEqual(parse_token_response(self.json_notype), self.json_notype_dict) + try: + os.environ['OAUTHLIB_STRICT_TOKEN_TYPE'] = '1' + self.assertRaises(MissingTokenTypeError, parse_token_response, self.json_notype) + finally: + del os.environ['OAUTHLIB_STRICT_TOKEN_TYPE'] + + def test_url_encoded_token_response(self): + """Verify fallback parameter parsing and validation for token responses. """ + self.assertEqual(parse_token_response(self.url_encoded_response), self.json_dict) + self.assertRaises(AccessDeniedError, parse_token_response, self.url_encoded_error) + self.assertRaises(MissingTokenError, parse_token_response, self.url_encoded_notoken) + + scope_changes_recorded = [] + def record_scope_change(sender, message, old, new): + scope_changes_recorded.append((message, old, new)) + + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' + signals.scope_changed.connect(record_scope_change) + try: + token = parse_token_response(self.url_encoded_response, scope='aaa') + self.assertEqual(len(scope_changes_recorded), 1) + message, old, new = scope_changes_recorded[0] + for scope in new + old: + self.assertIn(scope, message) + self.assertEqual(old, ['aaa']) + self.assertEqual(set(new), {'abc', 'def'}) + finally: + signals.scope_changed.disconnect(record_scope_change) + del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/test_request_validator.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_request_validator.py new file mode 100644 index 0000000000..7a8d06b668 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_request_validator.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from oauthlib.oauth2 import RequestValidator + +from tests.unittest import TestCase + + +class RequestValidatorTest(TestCase): + + def test_method_contracts(self): + v = RequestValidator() + self.assertRaises(NotImplementedError, v.authenticate_client, 'r') + self.assertRaises(NotImplementedError, v.authenticate_client_id, + 'client_id', 'r') + self.assertRaises(NotImplementedError, v.confirm_redirect_uri, + 'client_id', 'code', 'redirect_uri', 'client', 'request') + self.assertRaises(NotImplementedError, v.get_default_redirect_uri, + 'client_id', 'request') + self.assertRaises(NotImplementedError, v.get_default_scopes, + 'client_id', 'request') + self.assertRaises(NotImplementedError, v.get_original_scopes, + 'refresh_token', 'request') + self.assertFalse(v.is_within_original_scope( + ['scope'], 'refresh_token', 'request')) + self.assertRaises(NotImplementedError, v.invalidate_authorization_code, + 'client_id', 'code', 'request') + self.assertRaises(NotImplementedError, v.save_authorization_code, + 'client_id', 'code', 'request') + self.assertRaises(NotImplementedError, v.save_bearer_token, + 'token', 'request') + self.assertRaises(NotImplementedError, v.validate_bearer_token, + 'token', 'scopes', 'request') + self.assertRaises(NotImplementedError, v.validate_client_id, + 'client_id', 'request') + self.assertRaises(NotImplementedError, v.validate_code, + 'client_id', 'code', 'client', 'request') + self.assertRaises(NotImplementedError, v.validate_grant_type, + 'client_id', 'grant_type', 'client', 'request') + self.assertRaises(NotImplementedError, v.validate_redirect_uri, + 'client_id', 'redirect_uri', 'request') + self.assertRaises(NotImplementedError, v.validate_refresh_token, + 'refresh_token', 'client', 'request') + self.assertRaises(NotImplementedError, v.validate_response_type, + 'client_id', 'response_type', 'client', 'request') + self.assertRaises(NotImplementedError, v.validate_scopes, + 'client_id', 'scopes', 'client', 'request') + self.assertRaises(NotImplementedError, v.validate_user, + 'username', 'password', 'client', 'request') + self.assertTrue(v.client_authentication_required('r')) + self.assertFalse( + v.is_origin_allowed('client_id', 'https://foo.bar', 'r') + ) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/test_server.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_server.py new file mode 100644 index 0000000000..94af37e56b --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_server.py @@ -0,0 +1,391 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib import common +from oauthlib.oauth2.rfc6749 import errors, tokens +from oauthlib.oauth2.rfc6749.endpoints import Server +from oauthlib.oauth2.rfc6749.endpoints.authorization import ( + AuthorizationEndpoint, +) +from oauthlib.oauth2.rfc6749.endpoints.resource import ResourceEndpoint +from oauthlib.oauth2.rfc6749.endpoints.token import TokenEndpoint +from oauthlib.oauth2.rfc6749.grant_types import ( + AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, + ResourceOwnerPasswordCredentialsGrant, +) + +from tests.unittest import TestCase + + +class AuthorizationEndpointTest(TestCase): + + def setUp(self): + self.mock_validator = mock.MagicMock() + self.mock_validator.get_code_challenge.return_value = None + self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock()) + auth_code = AuthorizationCodeGrant( + request_validator=self.mock_validator) + auth_code.save_authorization_code = mock.MagicMock() + implicit = ImplicitGrant( + request_validator=self.mock_validator) + implicit.save_token = mock.MagicMock() + + response_types = { + 'code': auth_code, + 'token': implicit, + 'none': auth_code + } + self.expires_in = 1800 + token = tokens.BearerToken( + self.mock_validator, + expires_in=self.expires_in + ) + self.endpoint = AuthorizationEndpoint( + default_response_type='code', + default_token_type=token, + response_types=response_types + ) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_authorization_grant(self): + uri = 'http://i.b/l?response_type=code&client_id=me&scope=all+of+them&state=xyz' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?code=abc&state=xyz') + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_implicit_grant(self): + uri = 'http://i.b/l?response_type=token&client_id=me&scope=all+of+them&state=xyz' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me#access_token=abc&expires_in=' + str(self.expires_in) + '&token_type=Bearer&state=xyz&scope=all+of+them', parse_fragment=True) + + def test_none_grant(self): + uri = 'http://i.b/l?response_type=none&client_id=me&scope=all+of+them&state=xyz' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?state=xyz', parse_fragment=True) + self.assertIsNone(body) + self.assertEqual(status_code, 302) + + # and without the state parameter + uri = 'http://i.b/l?response_type=none&client_id=me&scope=all+of+them' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me', parse_fragment=True) + self.assertIsNone(body) + self.assertEqual(status_code, 302) + + def test_missing_type(self): + uri = 'http://i.b/l?client_id=me&scope=all+of+them' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + self.mock_validator.validate_request = mock.MagicMock( + side_effect=errors.InvalidRequestError()) + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?error=invalid_request&error_description=Missing+response_type+parameter.') + + def test_invalid_type(self): + uri = 'http://i.b/l?response_type=invalid&client_id=me&scope=all+of+them' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + self.mock_validator.validate_request = mock.MagicMock( + side_effect=errors.UnsupportedResponseTypeError()) + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?error=unsupported_response_type') + + +class TokenEndpointTest(TestCase): + + def setUp(self): + def set_user(request): + request.user = mock.MagicMock() + request.client = mock.MagicMock() + request.client.client_id = 'mocked_client_id' + return True + + self.mock_validator = mock.MagicMock() + self.mock_validator.authenticate_client.side_effect = set_user + self.mock_validator.get_code_challenge.return_value = None + self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock()) + auth_code = AuthorizationCodeGrant( + request_validator=self.mock_validator) + password = ResourceOwnerPasswordCredentialsGrant( + request_validator=self.mock_validator) + client = ClientCredentialsGrant( + request_validator=self.mock_validator) + supported_types = { + 'authorization_code': auth_code, + 'password': password, + 'client_credentials': client, + } + self.expires_in = 1800 + token = tokens.BearerToken( + self.mock_validator, + expires_in=self.expires_in + ) + self.endpoint = TokenEndpoint( + 'authorization_code', + default_token_type=token, + grant_types=supported_types + ) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_authorization_grant(self): + body = 'grant_type=authorization_code&code=abc&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': 'abc', + 'refresh_token': 'abc', + 'scope': 'all of them' + } + self.assertEqual(json.loads(body), token) + + body = 'grant_type=authorization_code&code=abc' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': 'abc', + 'refresh_token': 'abc' + } + self.assertEqual(json.loads(body), token) + + # try with additional custom variables + body = 'grant_type=authorization_code&code=abc&state=foobar' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + self.assertEqual(json.loads(body), token) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_password_grant(self): + body = 'grant_type=password&username=a&password=hello&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': 'abc', + 'refresh_token': 'abc', + 'scope': 'all of them', + } + self.assertEqual(json.loads(body), token) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_client_grant(self): + body = 'grant_type=client_credentials&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': 'abc', + 'scope': 'all of them', + } + self.assertEqual(json.loads(body), token) + + def test_missing_type(self): + _, body, _ = self.endpoint.create_token_response('', body='') + token = {'error': 'unsupported_grant_type'} + self.assertEqual(json.loads(body), token) + + def test_invalid_type(self): + body = 'grant_type=invalid' + _, body, _ = self.endpoint.create_token_response('', body=body) + token = {'error': 'unsupported_grant_type'} + self.assertEqual(json.loads(body), token) + + +class SignedTokenEndpointTest(TestCase): + + def setUp(self): + self.expires_in = 1800 + + def set_user(request): + request.user = mock.MagicMock() + request.client = mock.MagicMock() + request.client.client_id = 'mocked_client_id' + return True + + self.mock_validator = mock.MagicMock() + self.mock_validator.get_code_challenge.return_value = None + self.mock_validator.authenticate_client.side_effect = set_user + self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock()) + + self.private_pem = """ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA6TtDhWGwzEOWZP6m/zHoZnAPLABfetvoMPmxPGjFjtDuMRPv +EvI1sbixZBjBtdnc5rTtHUUQ25Am3JzwPRGo5laMGbj1pPyCPxlVi9LK82HQNX0B +YK7tZtVfDHElQA7F4v3j9d3rad4O9/n+lyGIQ0tT7yQcBm2A8FEaP0bZYCLMjwMN +WfaVLE8eXHyv+MfpNNLI9wttLxygKYM48I3NwsFuJgOa/KuodXaAmf8pJnx8t1Wn +nxvaYXFiUn/TxmhM/qhemPa6+0nqq+aWV5eT7xn4K/ghLgNs09v6Yge0pmPl9Oz+ ++bjJ+aKRnAmwCOY8/5U5EilAiUOeBoO9+8OXtwIDAQABAoIBAGFTTbXXMkPK4HN8 +oItVdDlrAanG7hECuz3UtFUVE3upS/xG6TjqweVLwRqYCh2ssDXFwjy4mXRGDzF4 +e/e/6s9Txlrlh/w1MtTJ6ZzTdcViR9RKOczysjZ7S5KRlI3KnGFAuWPcG2SuOWjZ +dZfzcj1Crd/ZHajBAVFHRsCo/ATVNKbTRprFfb27xKpQ2BwH/GG781sLE3ZVNIhs +aRRaED4622kI1E/WXws2qQMqbFKzo0m1tPbLb3Z89WgZJ/tRQwuDype1Vfm7k6oX +xfbp3948qSe/yWKRlMoPkleji/WxPkSIalzWSAi9ziN/0Uzhe65FURgrfHL3XR1A +B8UR+aECgYEA7NPQZV4cAikk02Hv65JgISofqV49P8MbLXk8sdnI1n7Mj10TgzU3 +lyQGDEX4hqvT0bTXe4KAOxQZx9wumu05ejfzhdtSsEm6ptGHyCdmYDQeV0C/pxDX +JNCK8XgMku2370XG0AnyBCT7NGlgtDcNCQufcesF2gEuoKiXg6Zjo7sCgYEA/Bzs +9fWGZZnSsMSBSW2OYbFuhF3Fne0HcxXQHipl0Rujc/9g0nccwqKGizn4fGOE7a8F +usQgJoeGcinL7E9OEP/uQ9VX1C9RNVjIxP1O5/Guw1zjxQQYetOvbPhN2QhD1Ye7 +0TRKrW1BapcjwLpFQlVg1ZeTPOi5lv24W/wX9jUCgYEAkrMSX/hPuTbrTNVZ3L6r +NV/2hN+PaTPeXei/pBuXwOaCqDurnpcUfFcgN/IP5LwDVd+Dq0pHTFFDNv45EFbq +R77o5n3ZVsIVEMiyJ1XgoK8oLDw7e61+15smtjT69Piz+09pu+ytMcwGn4y3Dmsb +dALzHYnL8iLRU0ubrz0ec4kCgYAJiVKRTzNBPptQom49h85d9ac3jJCAE8o3WTjh +Gzt0uHXrWlqgO280EY/DTnMOyXjqwLcXxHlu26uDP/99tdY/IF8z46sJ1KxetzgI +84f7kBHLRAU9m5UNeFpnZdEUB5MBTbwWAsNcYgiabpMkpCcghjg+fBhOsoLqqjhC +CnwhjQKBgQDkv0QTdyBU84TE8J0XY3eLQwXbrvG2yD5A2ntN3PyxGEneX5WTJGMZ +xJxwaFYQiDS3b9E7b8Q5dg8qa5Y1+epdhx3cuQAWPm+AoHKshDfbRve4txBDQAqh +c6MxSWgsa+2Ld5SWSNbGtpPcmEM3Fl5ttMCNCKtNc0UE16oHwaPAIw== +-----END RSA PRIVATE KEY----- + """ + + self.public_pem = """ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6TtDhWGwzEOWZP6m/zHo +ZnAPLABfetvoMPmxPGjFjtDuMRPvEvI1sbixZBjBtdnc5rTtHUUQ25Am3JzwPRGo +5laMGbj1pPyCPxlVi9LK82HQNX0BYK7tZtVfDHElQA7F4v3j9d3rad4O9/n+lyGI +Q0tT7yQcBm2A8FEaP0bZYCLMjwMNWfaVLE8eXHyv+MfpNNLI9wttLxygKYM48I3N +wsFuJgOa/KuodXaAmf8pJnx8t1WnnxvaYXFiUn/TxmhM/qhemPa6+0nqq+aWV5eT +7xn4K/ghLgNs09v6Yge0pmPl9Oz++bjJ+aKRnAmwCOY8/5U5EilAiUOeBoO9+8OX +twIDAQAB +-----END PUBLIC KEY----- + """ + + signed_token = tokens.signed_token_generator(self.private_pem, + user_id=123) + self.endpoint = Server( + self.mock_validator, + token_expires_in=self.expires_in, + token_generator=signed_token, + refresh_token_generator=tokens.random_token_generator + ) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_authorization_grant(self): + body = 'client_id=me&redirect_uri=http%3A%2F%2Fback.to%2Fme&grant_type=authorization_code&code=abc&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + body = json.loads(body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': body['access_token'], + 'refresh_token': 'abc', + 'scope': 'all of them' + } + self.assertEqual(body, token) + + body = 'client_id=me&redirect_uri=http%3A%2F%2Fback.to%2Fme&grant_type=authorization_code&code=abc' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + body = json.loads(body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': body['access_token'], + 'refresh_token': 'abc' + } + self.assertEqual(body, token) + + # try with additional custom variables + body = 'client_id=me&redirect_uri=http%3A%2F%2Fback.to%2Fme&grant_type=authorization_code&code=abc&state=foobar' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + body = json.loads(body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': body['access_token'], + 'refresh_token': 'abc' + } + self.assertEqual(body, token) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_password_grant(self): + body = 'grant_type=password&username=a&password=hello&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + body = json.loads(body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': body['access_token'], + 'refresh_token': 'abc', + 'scope': 'all of them', + } + self.assertEqual(body, token) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_scopes_and_user_id_stored_in_access_token(self): + body = 'grant_type=password&username=a&password=hello&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + + access_token = json.loads(body)['access_token'] + + claims = common.verify_signed_token(self.public_pem, access_token) + + self.assertEqual(claims['scope'], 'all of them') + self.assertEqual(claims['user_id'], 123) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_client_grant(self): + body = 'grant_type=client_credentials&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + body = json.loads(body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': body['access_token'], + 'scope': 'all of them', + } + self.assertEqual(body, token) + + def test_missing_type(self): + _, body, _ = self.endpoint.create_token_response('', body='client_id=me&redirect_uri=http%3A%2F%2Fback.to%2Fme&code=abc') + token = {'error': 'unsupported_grant_type'} + self.assertEqual(json.loads(body), token) + + def test_invalid_type(self): + body = 'client_id=me&redirect_uri=http%3A%2F%2Fback.to%2Fme&grant_type=invalid&code=abc' + _, body, _ = self.endpoint.create_token_response('', body=body) + token = {'error': 'unsupported_grant_type'} + self.assertEqual(json.loads(body), token) + + +class ResourceEndpointTest(TestCase): + + def setUp(self): + self.mock_validator = mock.MagicMock() + self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock()) + token = tokens.BearerToken(request_validator=self.mock_validator) + self.endpoint = ResourceEndpoint( + default_token='Bearer', + token_types={'Bearer': token} + ) + + def test_defaults(self): + uri = 'http://a.b/path?some=query' + self.mock_validator.validate_bearer_token.return_value = False + valid, request = self.endpoint.verify_request(uri) + self.assertFalse(valid) + self.assertEqual(request.token_type, 'Bearer') diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/test_tokens.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_tokens.py new file mode 100644 index 0000000000..fa6b1c092c --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_tokens.py @@ -0,0 +1,170 @@ +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749.tokens import ( + BearerToken, prepare_bearer_body, prepare_bearer_headers, + prepare_bearer_uri, prepare_mac_header, +) + +from tests.unittest import TestCase + + +class TokenTest(TestCase): + + # MAC without body/payload or extension + mac_plain = { + 'token': 'h480djs93hd8', + 'uri': 'http://example.com/resource/1?b=1&a=2', + 'key': '489dks293j39', + 'http_method': 'GET', + 'nonce': '264095:dj83hs9s', + 'hash_algorithm': 'hmac-sha-1' + } + auth_plain = { + 'Authorization': 'MAC id="h480djs93hd8", nonce="264095:dj83hs9s",' + ' mac="SLDJd4mg43cjQfElUs3Qub4L6xE="' + } + + # MAC with body/payload, no extension + mac_body = { + 'token': 'jd93dh9dh39D', + 'uri': 'http://example.com/request', + 'key': '8yfrufh348h', + 'http_method': 'POST', + 'nonce': '273156:di3hvdf8', + 'hash_algorithm': 'hmac-sha-1', + 'body': 'hello=world%21' + } + auth_body = { + 'Authorization': 'MAC id="jd93dh9dh39D", nonce="273156:di3hvdf8",' + ' bodyhash="k9kbtCIy0CkI3/FEfpS/oIDjk6k=", mac="W7bdMZbv9UWOTadASIQHagZyirA="' + } + + # MAC with body/payload and extension + mac_both = { + 'token': 'h480djs93hd8', + 'uri': 'http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b&c2&a3=2+q', + 'key': '489dks293j39', + 'http_method': 'GET', + 'nonce': '264095:7d8f3e4a', + 'hash_algorithm': 'hmac-sha-1', + 'body': 'Hello World!', + 'ext': 'a,b,c' + } + auth_both = { + 'Authorization': 'MAC id="h480djs93hd8", nonce="264095:7d8f3e4a",' + ' bodyhash="Lve95gjOVATpfV8EL5X4nxwjKHE=", ext="a,b,c",' + ' mac="Z3C2DojEopRDIC88/imW8Ez853g="' + } + + # Bearer + token = 'vF9dft4qmT' + uri = 'http://server.example.com/resource' + bearer_headers = { + 'Authorization': 'Bearer vF9dft4qmT' + } + valid_bearer_header_lowercase = {"Authorization": "bearer vF9dft4qmT"} + fake_bearer_headers = [ + {'Authorization': 'Beaver vF9dft4qmT'}, + {'Authorization': 'BeavervF9dft4qmT'}, + {'Authorization': 'Beaver vF9dft4qmT'}, + {'Authorization': 'BearerF9dft4qmT'}, + {'Authorization': 'Bearer vF9d ft4qmT'}, + ] + valid_header_with_multiple_spaces = {'Authorization': 'Bearer vF9dft4qmT'} + bearer_body = 'access_token=vF9dft4qmT' + bearer_uri = 'http://server.example.com/resource?access_token=vF9dft4qmT' + + def _mocked_validate_bearer_token(self, token, scopes, request): + if not token: + return False + return True + + def test_prepare_mac_header(self): + """Verify mac signatures correctness + + TODO: verify hmac-sha-256 + """ + self.assertEqual(prepare_mac_header(**self.mac_plain), self.auth_plain) + self.assertEqual(prepare_mac_header(**self.mac_body), self.auth_body) + self.assertEqual(prepare_mac_header(**self.mac_both), self.auth_both) + + def test_prepare_bearer_request(self): + """Verify proper addition of bearer tokens to requests. + + They may be represented as query components in body or URI or + in a Bearer authorization header. + """ + self.assertEqual(prepare_bearer_headers(self.token), self.bearer_headers) + self.assertEqual(prepare_bearer_body(self.token), self.bearer_body) + self.assertEqual(prepare_bearer_uri(self.token, uri=self.uri), self.bearer_uri) + + def test_valid_bearer_is_validated(self): + request_validator = mock.MagicMock() + request_validator.validate_bearer_token = self._mocked_validate_bearer_token + + request = Request("/", headers=self.bearer_headers) + result = BearerToken(request_validator=request_validator).validate_request( + request + ) + self.assertTrue(result) + + def test_lowercase_bearer_is_validated(self): + request_validator = mock.MagicMock() + request_validator.validate_bearer_token = self._mocked_validate_bearer_token + + request = Request("/", headers=self.valid_bearer_header_lowercase) + result = BearerToken(request_validator=request_validator).validate_request( + request + ) + self.assertTrue(result) + + def test_fake_bearer_is_not_validated(self): + request_validator = mock.MagicMock() + request_validator.validate_bearer_token = self._mocked_validate_bearer_token + + for fake_header in self.fake_bearer_headers: + request = Request("/", headers=fake_header) + result = BearerToken(request_validator=request_validator).validate_request( + request + ) + + self.assertFalse(result) + + def test_header_with_multispaces_is_validated(self): + request_validator = mock.MagicMock() + request_validator.validate_bearer_token = self._mocked_validate_bearer_token + + request = Request("/", headers=self.valid_header_with_multiple_spaces) + result = BearerToken(request_validator=request_validator).validate_request( + request + ) + + self.assertTrue(result) + + def test_estimate_type(self): + request_validator = mock.MagicMock() + request_validator.validate_bearer_token = self._mocked_validate_bearer_token + request = Request("/", headers=self.bearer_headers) + result = BearerToken(request_validator=request_validator).estimate_type(request) + self.assertEqual(result, 9) + + def test_estimate_type_with_fake_header_returns_type_0(self): + request_validator = mock.MagicMock() + request_validator.validate_bearer_token = self._mocked_validate_bearer_token + + for fake_header in self.fake_bearer_headers: + request = Request("/", headers=fake_header) + result = BearerToken(request_validator=request_validator).estimate_type( + request + ) + + if ( + fake_header["Authorization"].count(" ") == 2 + and fake_header["Authorization"].split()[0] == "Bearer" + ): + # If we're dealing with the header containing 2 spaces, it will be recognized + # as a Bearer valid header, the token itself will be invalid by the way. + self.assertEqual(result, 9) + else: + self.assertEqual(result, 0) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc6749/test_utils.py b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_utils.py new file mode 100644 index 0000000000..3299591926 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc6749/test_utils.py @@ -0,0 +1,100 @@ +import datetime +import os + +from oauthlib.oauth2.rfc6749.utils import ( + escape, generate_age, host_from_uri, is_secure_transport, list_to_scope, + params_from_uri, scope_to_list, +) + +from tests.unittest import TestCase + + +class ScopeObject: + """ + Fixture for testing list_to_scope()/scope_to_list() with objects other + than regular strings. + """ + def __init__(self, scope): + self.scope = scope + + def __str__(self): + return self.scope + + +class UtilsTests(TestCase): + + def test_escape(self): + """Assert that we are only escaping unicode""" + self.assertRaises(ValueError, escape, b"I am a string type. Not a unicode type.") + self.assertEqual(escape("I am a unicode type."), "I%20am%20a%20unicode%20type.") + + def test_host_from_uri(self): + """Test if hosts and ports are properly extracted from URIs. + + This should be done according to the MAC Authentication spec. + Defaults ports should be provided when none is present in the URI. + """ + self.assertEqual(host_from_uri('http://a.b-c.com:8080'), ('a.b-c.com', '8080')) + self.assertEqual(host_from_uri('https://a.b.com:8080'), ('a.b.com', '8080')) + self.assertEqual(host_from_uri('http://www.example.com'), ('www.example.com', '80')) + self.assertEqual(host_from_uri('https://www.example.com'), ('www.example.com', '443')) + + def test_is_secure_transport(self): + """Test check secure uri.""" + if 'OAUTHLIB_INSECURE_TRANSPORT' in os.environ: + del os.environ['OAUTHLIB_INSECURE_TRANSPORT'] + + self.assertTrue(is_secure_transport('https://example.com')) + self.assertFalse(is_secure_transport('http://example.com')) + + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + self.assertTrue(is_secure_transport('http://example.com')) + del os.environ['OAUTHLIB_INSECURE_TRANSPORT'] + + def test_params_from_uri(self): + self.assertEqual(params_from_uri('http://i.b/?foo=bar&g&scope=a+d'), + {'foo': 'bar', 'g': '', 'scope': ['a', 'd']}) + + def test_generate_age(self): + issue_time = datetime.datetime.now() - datetime.timedelta( + days=3, minutes=1, seconds=4) + self.assertGreater(float(generate_age(issue_time)), 259263.0) + + def test_list_to_scope(self): + expected = 'foo bar baz' + + string_list = ['foo', 'bar', 'baz'] + self.assertEqual(list_to_scope(string_list), expected) + + string_tuple = ('foo', 'bar', 'baz') + self.assertEqual(list_to_scope(string_tuple), expected) + + obj_list = [ScopeObject('foo'), ScopeObject('bar'), ScopeObject('baz')] + self.assertEqual(list_to_scope(obj_list), expected) + + set_list = set(string_list) + set_scope = list_to_scope(set_list) + assert len(set_scope.split(' ')) == 3 + for x in string_list: + assert x in set_scope + + self.assertRaises(ValueError, list_to_scope, object()) + + def test_scope_to_list(self): + expected = ['foo', 'bar', 'baz'] + + string_scopes = 'foo bar baz ' + self.assertEqual(scope_to_list(string_scopes), expected) + + string_list_scopes = ['foo', 'bar', 'baz'] + self.assertEqual(scope_to_list(string_list_scopes), expected) + + tuple_list_scopes = ('foo', 'bar', 'baz') + self.assertEqual(scope_to_list(tuple_list_scopes), expected) + + obj_list_scopes = [ScopeObject('foo'), ScopeObject('bar'), ScopeObject('baz')] + self.assertEqual(scope_to_list(obj_list_scopes), expected) + + set_list_scopes = set(string_list_scopes) + set_list = scope_to_list(set_list_scopes) + self.assertEqual(sorted(set_list), sorted(string_list_scopes)) diff --git a/contrib/python/oauthlib/tests/oauth2/rfc8628/__init__.py b/contrib/python/oauthlib/tests/oauth2/rfc8628/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc8628/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth2/rfc8628/clients/__init__.py b/contrib/python/oauthlib/tests/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc8628/clients/__init__.py diff --git a/contrib/python/oauthlib/tests/oauth2/rfc8628/clients/test_device.py b/contrib/python/oauthlib/tests/oauth2/rfc8628/clients/test_device.py new file mode 100644 index 0000000000..725dea2a92 --- /dev/null +++ b/contrib/python/oauthlib/tests/oauth2/rfc8628/clients/test_device.py @@ -0,0 +1,63 @@ +import os +from unittest.mock import patch + +from oauthlib import signals +from oauthlib.oauth2 import DeviceClient + +from tests.unittest import TestCase + + +class DeviceClientTest(TestCase): + + client_id = "someclientid" + kwargs = { + "some": "providers", + "require": "extra arguments" + } + + client_secret = "asecret" + + device_code = "somedevicecode" + + scope = ["profile", "email"] + + body = "not=empty" + + body_up = "not=empty&grant_type=urn:ietf:params:oauth:grant-type:device_code" + body_code = body_up + "&device_code=somedevicecode" + body_kwargs = body_code + "&some=providers&require=extra+arguments" + + uri = "https://example.com/path?query=world" + uri_id = uri + "&client_id=" + client_id + uri_grant = uri_id + "&grant_type=urn:ietf:params:oauth:grant-type:device_code" + uri_secret = uri_grant + "&client_secret=asecret" + uri_scope = uri_secret + "&scope=profile+email" + + def test_request_body(self): + client = DeviceClient(self.client_id) + + # Basic, no extra arguments + body = client.prepare_request_body(self.device_code, body=self.body) + self.assertFormBodyEqual(body, self.body_code) + + rclient = DeviceClient(self.client_id) + body = rclient.prepare_request_body(self.device_code, body=self.body) + self.assertFormBodyEqual(body, self.body_code) + + # With extra parameters + body = client.prepare_request_body( + self.device_code, body=self.body, **self.kwargs) + self.assertFormBodyEqual(body, self.body_kwargs) + + def test_request_uri(self): + client = DeviceClient(self.client_id) + + uri = client.prepare_request_uri(self.uri) + self.assertURLEqual(uri, self.uri_grant) + + client = DeviceClient(self.client_id, client_secret=self.client_secret) + uri = client.prepare_request_uri(self.uri) + self.assertURLEqual(uri, self.uri_secret) + + uri = client.prepare_request_uri(self.uri, scope=self.scope) + self.assertURLEqual(uri, self.uri_scope) diff --git a/contrib/python/oauthlib/tests/openid/__init__.py b/contrib/python/oauthlib/tests/openid/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/__init__.py diff --git a/contrib/python/oauthlib/tests/openid/connect/__init__.py b/contrib/python/oauthlib/tests/openid/connect/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/__init__.py diff --git a/contrib/python/oauthlib/tests/openid/connect/core/__init__.py b/contrib/python/oauthlib/tests/openid/connect/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/__init__.py diff --git a/contrib/python/oauthlib/tests/openid/connect/core/endpoints/__init__.py b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/__init__.py diff --git a/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_claims_handling.py b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_claims_handling.py new file mode 100644 index 0000000000..301ed1aa44 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_claims_handling.py @@ -0,0 +1,107 @@ +"""Ensure OpenID Connect Authorization Request 'claims' are preserved across authorization. + +The claims parameter is an optional query param for the Authorization Request endpoint + but if it is provided and is valid it needs to be deserialized (from urlencoded JSON) + and persisted with the authorization code itself, then in the subsequent Access Token + request the claims should be transferred (via the oauthlib request) to be persisted + with the Access Token when it is created. +""" +from unittest import mock + +from oauthlib.openid import RequestValidator +from oauthlib.openid.connect.core.endpoints.pre_configured import Server + +from __tests__.oauth2.rfc6749.endpoints.test_utils import get_query_credentials +from tests.unittest import TestCase + + +class TestClaimsHandling(TestCase): + + DEFAULT_REDIRECT_URI = 'http://i.b./path' + + def set_scopes(self, scopes): + def set_request_scopes(client_id, code, client, request): + request.scopes = scopes + return True + return set_request_scopes + + def set_user(self, request): + request.user = 'foo' + request.client_id = 'bar' + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def save_claims_with_code(self, client_id, code, request, *args, **kwargs): + # a real validator would save the claims with the code during save_authorization_code() + self.claims_from_auth_code_request = request.claims + self.scopes = request.scopes.split() + + def retrieve_claims_saved_with_code(self, client_id, code, client, request, *args, **kwargs): + request.claims = self.claims_from_auth_code_request + request.scopes = self.scopes + + return True + + def save_claims_with_bearer_token(self, token, request, *args, **kwargs): + # a real validator would save the claims with the access token during save_bearer_token() + self.claims_saved_with_bearer_token = request.claims + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_code_challenge.return_value = None + self.validator.get_default_redirect_uri.return_value = TestClaimsHandling.DEFAULT_REDIRECT_URI + self.validator.authenticate_client.side_effect = self.set_client + + self.validator.save_authorization_code.side_effect = self.save_claims_with_code + self.validator.validate_code.side_effect = self.retrieve_claims_saved_with_code + self.validator.save_token.side_effect = self.save_claims_with_bearer_token + + self.server = Server(self.validator) + + def test_claims_stored_on_code_creation(self): + + claims = { + "id_token": { + "claim_1": None, + "claim_2": { + "essential": True + } + }, + "userinfo": { + "claim_3": { + "essential": True + }, + "claim_4": None + } + } + + claims_urlquoted = '%7B%22id_token%22%3A%20%7B%22claim_2%22%3A%20%7B%22essential%22%3A%20true%7D%2C%20%22claim_1%22%3A%20null%7D%2C%20%22userinfo%22%3A%20%7B%22claim_4%22%3A%20null%2C%20%22claim_3%22%3A%20%7B%22essential%22%3A%20true%7D%7D%7D' + uri = 'http://example.com/path?client_id=abc&scope=openid+test_scope&response_type=code&claims=%s' + + h, b, s = self.server.create_authorization_response(uri % claims_urlquoted, scopes='openid test_scope') + + self.assertDictEqual(self.claims_from_auth_code_request, claims) + + code = get_query_credentials(h['Location'])['code'][0] + token_uri = 'http://example.com/path' + _, body, _ = self.server.create_token_response( + token_uri, + body='client_id=me&redirect_uri=http://back.to/me&grant_type=authorization_code&code=%s' % code + ) + + self.assertDictEqual(self.claims_saved_with_bearer_token, claims) + + def test_invalid_claims(self): + uri = 'http://example.com/path?client_id=abc&scope=openid+test_scope&response_type=code&claims=this-is-not-json' + + h, b, s = self.server.create_authorization_response(uri, scopes='openid test_scope') + error = get_query_credentials(h['Location'])['error'][0] + error_desc = get_query_credentials(h['Location'])['error_description'][0] + self.assertEqual(error, 'invalid_request') + self.assertEqual(error_desc, "Malformed claims parameter") diff --git a/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py new file mode 100644 index 0000000000..c55136fbf1 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py @@ -0,0 +1,78 @@ +from unittest import mock +from urllib.parse import urlencode + +from oauthlib.oauth2 import InvalidRequestError +from oauthlib.oauth2.rfc6749.endpoints.authorization import ( + AuthorizationEndpoint, +) +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.openid.connect.core.grant_types import AuthorizationCodeGrant + +from tests.unittest import TestCase + + +class OpenIDConnectEndpointTest(TestCase): + + def setUp(self): + self.mock_validator = mock.MagicMock() + self.mock_validator.authenticate_client.side_effect = self.set_client + grant = AuthorizationCodeGrant(request_validator=self.mock_validator) + bearer = BearerToken(self.mock_validator) + self.endpoint = AuthorizationEndpoint(grant, bearer, + response_types={'code': grant}) + params = { + 'prompt': 'consent', + 'display': 'touch', + 'nonce': 'abcd', + 'state': 'abc', + 'redirect_uri': 'https://a.b/cb', + 'response_type': 'code', + 'client_id': 'abcdef', + 'scope': 'hello openid' + } + self.url = 'http://a.b/path?' + urlencode(params) + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + @mock.patch('oauthlib.common.generate_token') + def test_authorization_endpoint_handles_prompt(self, generate_token): + generate_token.return_value = "MOCK_CODE" + # In the GET view: + scopes, creds = self.endpoint.validate_authorization_request(self.url) + # In the POST view: + creds['scopes'] = scopes + h, b, s = self.endpoint.create_authorization_response(self.url, + credentials=creds) + expected = 'https://a.b/cb?state=abc&code=MOCK_CODE' + self.assertURLEqual(h['Location'], expected) + self.assertIsNone(b) + self.assertEqual(s, 302) + + def test_prompt_none_exclusiveness(self): + """ + Test that prompt=none can't be used with another prompt value. + """ + params = { + 'prompt': 'none consent', + 'state': 'abc', + 'redirect_uri': 'https://a.b/cb', + 'response_type': 'code', + 'client_id': 'abcdef', + 'scope': 'hello openid' + } + url = 'http://a.b/path?' + urlencode(params) + with self.assertRaises(InvalidRequestError): + self.endpoint.validate_authorization_request(url) + + def test_oidc_params_preservation(self): + """ + Test that the nonce parameter is passed through. + """ + scopes, creds = self.endpoint.validate_authorization_request(self.url) + + self.assertEqual(creds['prompt'], {'consent'}) + self.assertEqual(creds['nonce'], 'abcd') + self.assertEqual(creds['display'], 'touch') diff --git a/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_userinfo_endpoint.py b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_userinfo_endpoint.py new file mode 100644 index 0000000000..4833485195 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/endpoints/test_userinfo_endpoint.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.openid import RequestValidator, UserInfoEndpoint + +from tests.unittest import TestCase + + +def set_scopes_valid(token, scopes, request): + request.scopes = ["openid", "bar"] + return True + + +class UserInfoEndpointTest(TestCase): + def setUp(self): + self.claims = { + "sub": "john", + "fruit": "banana" + } + # Can't use MagicMock/wraps below. + # Triggers error when endpoint copies to self.bearer.request_validator + self.validator = RequestValidator() + self.validator.validate_bearer_token = mock.Mock() + self.validator.validate_bearer_token.side_effect = set_scopes_valid + self.validator.get_userinfo_claims = mock.Mock() + self.validator.get_userinfo_claims.return_value = self.claims + self.endpoint = UserInfoEndpoint(self.validator) + + self.uri = 'should_not_matter' + self.headers = { + 'Authorization': 'Bearer eyJxx' + } + + def test_userinfo_no_auth(self): + self.endpoint.create_userinfo_response(self.uri) + + def test_userinfo_wrong_auth(self): + self.headers['Authorization'] = 'Basic foifoifoi' + self.endpoint.create_userinfo_response(self.uri, headers=self.headers) + + def test_userinfo_token_expired(self): + self.validator.validate_bearer_token.return_value = False + self.endpoint.create_userinfo_response(self.uri, headers=self.headers) + + def test_userinfo_token_no_openid_scope(self): + def set_scopes_invalid(token, scopes, request): + request.scopes = ["foo", "bar"] + return True + self.validator.validate_bearer_token.side_effect = set_scopes_invalid + with self.assertRaises(errors.InsufficientScopeError) as context: + self.endpoint.create_userinfo_response(self.uri) + + def test_userinfo_json_response(self): + h, b, s = self.endpoint.create_userinfo_response(self.uri) + self.assertEqual(s, 200) + body_json = json.loads(b) + self.assertEqual(self.claims, body_json) + self.assertEqual("application/json", h['Content-Type']) + + def test_userinfo_jwt_response(self): + self.validator.get_userinfo_claims.return_value = "eyJzzzzz" + h, b, s = self.endpoint.create_userinfo_response(self.uri) + self.assertEqual(s, 200) + self.assertEqual(b, "eyJzzzzz") + self.assertEqual("application/jwt", h['Content-Type']) diff --git a/contrib/python/oauthlib/tests/openid/connect/core/grant_types/__init__.py b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/__init__.py diff --git a/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_authorization_code.py b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_authorization_code.py new file mode 100644 index 0000000000..49b03a7f7d --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_authorization_code.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749.errors import ( + ConsentRequired, InvalidRequestError, LoginRequired, +) +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.openid.connect.core.grant_types.authorization_code import ( + AuthorizationCodeGrant, +) + +from __tests__.oauth2.rfc6749.grant_types.test_authorization_code import ( + AuthorizationCodeGrantTest, +) +from tests.unittest import TestCase + + +def get_id_token_mock(token, token_handler, request): + return "MOCKED_TOKEN" + + +class OpenIDAuthCodeInterferenceTest(AuthorizationCodeGrantTest): + """Test that OpenID don't interfere with normal OAuth 2 flows.""" + + def setUp(self): + super().setUp() + self.auth = AuthorizationCodeGrant(request_validator=self.mock_validator) + + +class OpenIDAuthCodeTest(TestCase): + + def setUp(self): + self.request = Request('http://a.b/path') + self.request.scopes = ('hello', 'openid') + self.request.expires_in = 1800 + self.request.client_id = 'abcdef' + self.request.code = '1234' + self.request.response_type = 'code' + self.request.grant_type = 'authorization_code' + self.request.redirect_uri = 'https://a.b/cb' + self.request.state = 'abc' + self.request.nonce = None + + self.mock_validator = mock.MagicMock() + self.mock_validator.authenticate_client.side_effect = self.set_client + self.mock_validator.get_code_challenge.return_value = None + self.mock_validator.get_id_token.side_effect = get_id_token_mock + self.auth = AuthorizationCodeGrant(request_validator=self.mock_validator) + + self.url_query = 'https://a.b/cb?code=abc&state=abc' + self.url_fragment = 'https://a.b/cb#code=abc&state=abc' + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + @mock.patch('oauthlib.common.generate_token') + def test_authorization(self, generate_token): + + scope, info = self.auth.validate_authorization_request(self.request) + + generate_token.return_value = 'abc' + bearer = BearerToken(self.mock_validator) + self.request.response_mode = 'query' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_query) + self.assertIsNone(b) + self.assertEqual(s, 302) + + self.request.response_mode = 'fragment' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_fragment, parse_fragment=True) + self.assertIsNone(b) + self.assertEqual(s, 302) + + @mock.patch('oauthlib.common.generate_token') + def test_no_prompt_authorization(self, generate_token): + generate_token.return_value = 'abc' + self.request.prompt = 'none' + + bearer = BearerToken(self.mock_validator) + + self.request.response_mode = 'query' + self.request.id_token_hint = 'me@email.com' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_query) + self.assertIsNone(b) + self.assertEqual(s, 302) + + # Test alternative response modes + self.request.response_mode = 'fragment' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_fragment, parse_fragment=True) + + # Ensure silent authentication and authorization is done + self.mock_validator.validate_silent_login.return_value = False + self.mock_validator.validate_silent_authorization.return_value = True + self.assertRaises(LoginRequired, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=login_required', h['Location']) + + self.mock_validator.validate_silent_login.return_value = True + self.mock_validator.validate_silent_authorization.return_value = False + self.assertRaises(ConsentRequired, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=consent_required', h['Location']) + + # ID token hint must match logged in user + self.mock_validator.validate_silent_authorization.return_value = True + self.mock_validator.validate_user_match.return_value = False + self.assertRaises(LoginRequired, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=login_required', h['Location']) + + def test_none_multi_prompt(self): + bearer = BearerToken(self.mock_validator) + + self.request.prompt = 'none login' + self.assertRaises(InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + self.request.prompt = 'none consent' + self.assertRaises(InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + self.request.prompt = 'none select_account' + self.assertRaises(InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + self.request.prompt = 'consent none login' + self.assertRaises(InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + def set_scopes(self, client_id, code, client, request): + request.scopes = self.request.scopes + request.user = 'bob' + return True + + def test_create_token_response(self): + self.request.response_type = None + self.mock_validator.validate_code.side_effect = self.set_scopes + + bearer = BearerToken(self.mock_validator) + + h, token, s = self.auth.create_token_response(self.request, bearer) + token = json.loads(token) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('expires_in', token) + self.assertIn('scope', token) + self.assertIn('id_token', token) + self.assertIn('openid', token['scope']) + + self.mock_validator.reset_mock() + + self.request.scopes = ('hello', 'world') + h, token, s = self.auth.create_token_response(self.request, bearer) + token = json.loads(token) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('expires_in', token) + self.assertIn('scope', token) + self.assertNotIn('id_token', token) + self.assertNotIn('openid', token['scope']) + + @mock.patch('oauthlib.common.generate_token') + def test_optional_nonce(self, generate_token): + generate_token.return_value = 'abc' + self.request.nonce = 'xyz' + scope, info = self.auth.validate_authorization_request(self.request) + + bearer = BearerToken(self.mock_validator) + self.request.response_mode = 'query' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_query) + self.assertIsNone(b) + self.assertEqual(s, 302) diff --git a/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_base.py b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_base.py new file mode 100644 index 0000000000..a88834b807 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_base.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +import time +from unittest import mock + +from oauthlib.common import Request +from oauthlib.openid.connect.core.grant_types.base import GrantTypeBase + +from tests.unittest import TestCase + + +class GrantBase(GrantTypeBase): + """Class to test GrantTypeBase""" + def __init__(self, request_validator=None, **kwargs): + self.request_validator = request_validator + + +class IDTokenTest(TestCase): + + def setUp(self): + self.request = Request('http://a.b/path') + self.request.scopes = ('hello', 'openid') + self.request.expires_in = 1800 + self.request.client_id = 'abcdef' + self.request.code = '1234' + self.request.response_type = 'id_token' + self.request.grant_type = 'authorization_code' + self.request.redirect_uri = 'https://a.b/cb' + self.request.state = 'abc' + self.request.nonce = None + + self.mock_validator = mock.MagicMock() + self.mock_validator.get_id_token.return_value = None + self.mock_validator.finalize_id_token.return_value = "eyJ.body.signature" + self.token = {} + + self.grant = GrantBase(request_validator=self.mock_validator) + + self.url_query = 'https://a.b/cb?code=abc&state=abc' + self.url_fragment = 'https://a.b/cb#code=abc&state=abc' + + def test_id_token_hash(self): + self.assertEqual(self.grant.id_token_hash( + "Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk", + ), "LDktKdoQak3Pk0cnXxCltA", "hash differs from RFC") + + def test_get_id_token_no_openid(self): + self.request.scopes = ('hello') + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertNotIn("id_token", token) + + self.request.scopes = None + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertNotIn("id_token", token) + + self.request.scopes = () + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertNotIn("id_token", token) + + def test_get_id_token(self): + self.mock_validator.get_id_token.return_value = "toto" + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertIn("id_token", token) + self.assertEqual(token["id_token"], "toto") + + def test_finalize_id_token(self): + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertIn("id_token", token) + self.assertEqual(token["id_token"], "eyJ.body.signature") + id_token = self.mock_validator.finalize_id_token.call_args[0][0] + self.assertEqual(id_token['aud'], 'abcdef') + self.assertGreaterEqual(int(time.time()), id_token['iat']) + + def test_finalize_id_token_with_nonce(self): + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request, "my_nonce") + self.assertIn("id_token", token) + self.assertEqual(token["id_token"], "eyJ.body.signature") + id_token = self.mock_validator.finalize_id_token.call_args[0][0] + self.assertEqual(id_token['nonce'], 'my_nonce') + + def test_finalize_id_token_with_at_hash(self): + self.token["access_token"] = "Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk" + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertIn("id_token", token) + self.assertEqual(token["id_token"], "eyJ.body.signature") + id_token = self.mock_validator.finalize_id_token.call_args[0][0] + self.assertEqual(id_token['at_hash'], 'LDktKdoQak3Pk0cnXxCltA') + + def test_finalize_id_token_with_c_hash(self): + self.token["code"] = "Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk" + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertIn("id_token", token) + self.assertEqual(token["id_token"], "eyJ.body.signature") + id_token = self.mock_validator.finalize_id_token.call_args[0][0] + self.assertEqual(id_token['c_hash'], 'LDktKdoQak3Pk0cnXxCltA') + + def test_finalize_id_token_with_c_and_at_hash(self): + self.token["code"] = "Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk" + self.token["access_token"] = "Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk" + token = self.grant.add_id_token(self.token, "token_handler_mock", self.request) + self.assertIn("id_token", token) + self.assertEqual(token["id_token"], "eyJ.body.signature") + id_token = self.mock_validator.finalize_id_token.call_args[0][0] + self.assertEqual(id_token['at_hash'], 'LDktKdoQak3Pk0cnXxCltA') + self.assertEqual(id_token['c_hash'], 'LDktKdoQak3Pk0cnXxCltA') diff --git a/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_dispatchers.py b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_dispatchers.py new file mode 100644 index 0000000000..ccbada490d --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_dispatchers.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749.grant_types import ( + AuthorizationCodeGrant as OAuth2AuthorizationCodeGrant, + ImplicitGrant as OAuth2ImplicitGrant, +) +from oauthlib.openid.connect.core.grant_types.authorization_code import ( + AuthorizationCodeGrant, +) +from oauthlib.openid.connect.core.grant_types.dispatchers import ( + AuthorizationTokenGrantDispatcher, ImplicitTokenGrantDispatcher, +) +from oauthlib.openid.connect.core.grant_types.implicit import ImplicitGrant + +from tests.unittest import TestCase + + +class ImplicitTokenGrantDispatcherTest(TestCase): + def setUp(self): + self.request = Request('http://a.b/path') + request_validator = mock.MagicMock() + implicit_grant = OAuth2ImplicitGrant(request_validator) + openid_connect_implicit = ImplicitGrant(request_validator) + + self.dispatcher = ImplicitTokenGrantDispatcher( + default_grant=implicit_grant, + oidc_grant=openid_connect_implicit + ) + + def test_create_authorization_response_openid(self): + self.request.scopes = ('hello', 'openid') + self.request.response_type = 'id_token' + handler = self.dispatcher._handler_for_request(self.request) + self.assertIsInstance(handler, ImplicitGrant) + + def test_validate_authorization_request_openid(self): + self.request.scopes = ('hello', 'openid') + self.request.response_type = 'id_token' + handler = self.dispatcher._handler_for_request(self.request) + self.assertIsInstance(handler, ImplicitGrant) + + def test_create_authorization_response_oauth(self): + self.request.scopes = ('hello', 'world') + handler = self.dispatcher._handler_for_request(self.request) + self.assertIsInstance(handler, OAuth2ImplicitGrant) + + def test_validate_authorization_request_oauth(self): + self.request.scopes = ('hello', 'world') + handler = self.dispatcher._handler_for_request(self.request) + self.assertIsInstance(handler, OAuth2ImplicitGrant) + + +class DispatcherTest(TestCase): + def setUp(self): + self.request = Request('http://a.b/path') + self.request.decoded_body = ( + ("client_id", "me"), + ("code", "code"), + ("redirect_url", "https://a.b/cb"), + ) + + self.request_validator = mock.MagicMock() + self.auth_grant = OAuth2AuthorizationCodeGrant(self.request_validator) + self.openid_connect_auth = AuthorizationCodeGrant(self.request_validator) + + +class AuthTokenGrantDispatcherOpenIdTest(DispatcherTest): + + def setUp(self): + super().setUp() + self.request_validator.get_authorization_code_scopes.return_value = ('hello', 'openid') + self.dispatcher = AuthorizationTokenGrantDispatcher( + self.request_validator, + default_grant=self.auth_grant, + oidc_grant=self.openid_connect_auth + ) + + def test_create_token_response_openid(self): + handler = self.dispatcher._handler_for_request(self.request) + self.assertIsInstance(handler, AuthorizationCodeGrant) + self.assertTrue(self.dispatcher.request_validator.get_authorization_code_scopes.called) + + +class AuthTokenGrantDispatcherOpenIdWithoutCodeTest(DispatcherTest): + + def setUp(self): + super().setUp() + self.request.decoded_body = ( + ("client_id", "me"), + ("code", ""), + ("redirect_url", "https://a.b/cb"), + ) + self.request_validator.get_authorization_code_scopes.return_value = ('hello', 'openid') + self.dispatcher = AuthorizationTokenGrantDispatcher( + self.request_validator, + default_grant=self.auth_grant, + oidc_grant=self.openid_connect_auth + ) + + def test_create_token_response_openid_without_code(self): + handler = self.dispatcher._handler_for_request(self.request) + self.assertIsInstance(handler, OAuth2AuthorizationCodeGrant) + self.assertFalse(self.dispatcher.request_validator.get_authorization_code_scopes.called) + + +class AuthTokenGrantDispatcherOAuthTest(DispatcherTest): + + def setUp(self): + super().setUp() + self.request_validator.get_authorization_code_scopes.return_value = ('hello', 'world') + self.dispatcher = AuthorizationTokenGrantDispatcher( + self.request_validator, + default_grant=self.auth_grant, + oidc_grant=self.openid_connect_auth + ) + + def test_create_token_response_oauth(self): + handler = self.dispatcher._handler_for_request(self.request) + self.assertIsInstance(handler, OAuth2AuthorizationCodeGrant) + self.assertTrue(self.dispatcher.request_validator.get_authorization_code_scopes.called) diff --git a/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_hybrid.py b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_hybrid.py new file mode 100644 index 0000000000..111c8c5c4b --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_hybrid.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.openid.connect.core.grant_types.hybrid import HybridGrant + +from __tests__.oauth2.rfc6749.grant_types.test_authorization_code import ( + AuthorizationCodeGrantTest, +) + +from .test_authorization_code import OpenIDAuthCodeTest + + +class OpenIDHybridInterferenceTest(AuthorizationCodeGrantTest): + """Test that OpenID don't interfere with normal OAuth 2 flows.""" + + def setUp(self): + super().setUp() + self.auth = HybridGrant(request_validator=self.mock_validator) + + +class OpenIDHybridCodeTokenTest(OpenIDAuthCodeTest): + + def setUp(self): + super().setUp() + self.request.response_type = 'code token' + self.request.nonce = None + self.auth = HybridGrant(request_validator=self.mock_validator) + self.url_query = 'https://a.b/cb?code=abc&state=abc&token_type=Bearer&expires_in=3600&scope=hello+openid&access_token=abc' + self.url_fragment = 'https://a.b/cb#code=abc&state=abc&token_type=Bearer&expires_in=3600&scope=hello+openid&access_token=abc' + + @mock.patch('oauthlib.common.generate_token') + def test_optional_nonce(self, generate_token): + generate_token.return_value = 'abc' + self.request.nonce = 'xyz' + scope, info = self.auth.validate_authorization_request(self.request) + + bearer = BearerToken(self.mock_validator) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_fragment, parse_fragment=True) + self.assertIsNone(b) + self.assertEqual(s, 302) + + +class OpenIDHybridCodeIdTokenTest(OpenIDAuthCodeTest): + + def setUp(self): + super().setUp() + self.mock_validator.get_code_challenge.return_value = None + self.request.response_type = 'code id_token' + self.request.nonce = 'zxc' + self.auth = HybridGrant(request_validator=self.mock_validator) + token = 'MOCKED_TOKEN' + self.url_query = 'https://a.b/cb?code=abc&state=abc&id_token=%s' % token + self.url_fragment = 'https://a.b/cb#code=abc&state=abc&id_token=%s' % token + + @mock.patch('oauthlib.common.generate_token') + def test_required_nonce(self, generate_token): + generate_token.return_value = 'abc' + self.request.nonce = None + self.assertRaises(errors.InvalidRequestError, self.auth.validate_authorization_request, self.request) + + bearer = BearerToken(self.mock_validator) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + self.assertIsNone(b) + self.assertEqual(s, 302) + + def test_id_token_contains_nonce(self): + token = {} + self.mock_validator.get_id_token.side_effect = None + self.mock_validator.get_id_token.return_value = None + token = self.auth.add_id_token(token, None, self.request) + assert self.mock_validator.finalize_id_token.call_count == 1 + claims = self.mock_validator.finalize_id_token.call_args[0][0] + assert "nonce" in claims + + +class OpenIDHybridCodeIdTokenTokenTest(OpenIDAuthCodeTest): + + def setUp(self): + super().setUp() + self.mock_validator.get_code_challenge.return_value = None + self.request.response_type = 'code id_token token' + self.request.nonce = 'xyz' + self.auth = HybridGrant(request_validator=self.mock_validator) + token = 'MOCKED_TOKEN' + self.url_query = 'https://a.b/cb?code=abc&state=abc&token_type=Bearer&expires_in=3600&scope=hello+openid&access_token=abc&id_token=%s' % token + self.url_fragment = 'https://a.b/cb#code=abc&state=abc&token_type=Bearer&expires_in=3600&scope=hello+openid&access_token=abc&id_token=%s' % token + + @mock.patch('oauthlib.common.generate_token') + def test_required_nonce(self, generate_token): + generate_token.return_value = 'abc' + self.request.nonce = None + self.assertRaises(errors.InvalidRequestError, self.auth.validate_authorization_request, self.request) + + bearer = BearerToken(self.mock_validator) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + self.assertIsNone(b) + self.assertEqual(s, 302) diff --git a/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_implicit.py b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_implicit.py new file mode 100644 index 0000000000..825093138c --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_implicit.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.openid.connect.core.grant_types.implicit import ImplicitGrant + +from __tests__.oauth2.rfc6749.grant_types.test_implicit import ImplicitGrantTest +from tests.unittest import TestCase + +from .test_authorization_code import get_id_token_mock + + +class OpenIDImplicitInterferenceTest(ImplicitGrantTest): + """Test that OpenID don't interfere with normal OAuth 2 flows.""" + + def setUp(self): + super().setUp() + self.auth = ImplicitGrant(request_validator=self.mock_validator) + + +class OpenIDImplicitTest(TestCase): + + def setUp(self): + self.request = Request('http://a.b/path') + self.request.scopes = ('hello', 'openid') + self.request.expires_in = 1800 + self.request.client_id = 'abcdef' + self.request.response_type = 'id_token token' + self.request.redirect_uri = 'https://a.b/cb' + self.request.state = 'abc' + self.request.nonce = 'xyz' + + self.mock_validator = mock.MagicMock() + self.mock_validator.get_id_token.side_effect = get_id_token_mock + self.auth = ImplicitGrant(request_validator=self.mock_validator) + + token = 'MOCKED_TOKEN' + self.url_query = 'https://a.b/cb?state=abc&token_type=Bearer&expires_in=3600&scope=hello+openid&access_token=abc&id_token=%s' % token + self.url_fragment = 'https://a.b/cb#state=abc&token_type=Bearer&expires_in=3600&scope=hello+openid&access_token=abc&id_token=%s' % token + + @mock.patch('oauthlib.common.generate_token') + def test_authorization(self, generate_token): + scope, info = self.auth.validate_authorization_request(self.request) + + generate_token.return_value = 'abc' + bearer = BearerToken(self.mock_validator) + + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_fragment, parse_fragment=True) + self.assertIsNone(b) + self.assertEqual(s, 302) + + self.request.response_type = 'id_token' + token = 'MOCKED_TOKEN' + url = 'https://a.b/cb#state=abc&id_token=%s' % token + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], url, parse_fragment=True) + self.assertIsNone(b) + self.assertEqual(s, 302) + + @mock.patch('oauthlib.common.generate_token') + def test_no_prompt_authorization(self, generate_token): + generate_token.return_value = 'abc' + self.request.prompt = 'none' + + bearer = BearerToken(self.mock_validator) + + self.request.response_mode = 'query' + self.request.id_token_hint = 'me@email.com' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_query) + self.assertIsNone(b) + self.assertEqual(s, 302) + + # Test alternative response modes + self.request.response_mode = 'fragment' + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertURLEqual(h['Location'], self.url_fragment, parse_fragment=True) + + # Ensure silent authentication and authorization is done + self.mock_validator.validate_silent_login.return_value = False + self.mock_validator.validate_silent_authorization.return_value = True + self.assertRaises(errors.LoginRequired, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=login_required', h['Location']) + + self.mock_validator.validate_silent_login.return_value = True + self.mock_validator.validate_silent_authorization.return_value = False + self.assertRaises(errors.ConsentRequired, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=consent_required', h['Location']) + + # ID token hint must match logged in user + self.mock_validator.validate_silent_authorization.return_value = True + self.mock_validator.validate_user_match.return_value = False + self.assertRaises(errors.LoginRequired, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=login_required', h['Location']) + + def test_none_multi_prompt(self): + bearer = BearerToken(self.mock_validator) + + self.request.prompt = 'none login' + self.assertRaises(errors.InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + self.request.prompt = 'none consent' + self.assertRaises(errors.InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + self.request.prompt = 'none select_account' + self.assertRaises(errors.InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + self.request.prompt = 'consent none login' + self.assertRaises(errors.InvalidRequestError, + self.auth.validate_authorization_request, + self.request) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + + @mock.patch('oauthlib.common.generate_token') + def test_required_nonce(self, generate_token): + generate_token.return_value = 'abc' + self.request.nonce = None + self.assertRaises(errors.InvalidRequestError, self.auth.validate_authorization_request, self.request) + + bearer = BearerToken(self.mock_validator) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + self.assertIsNone(b) + self.assertEqual(s, 302) + + +class OpenIDImplicitNoAccessTokenTest(OpenIDImplicitTest): + def setUp(self): + super().setUp() + self.request.response_type = 'id_token' + token = 'MOCKED_TOKEN' + self.url_query = 'https://a.b/cb?state=abc&id_token=%s' % token + self.url_fragment = 'https://a.b/cb#state=abc&id_token=%s' % token + + @mock.patch('oauthlib.common.generate_token') + def test_required_nonce(self, generate_token): + generate_token.return_value = 'abc' + self.request.nonce = None + self.assertRaises(errors.InvalidRequestError, self.auth.validate_authorization_request, self.request) + + bearer = BearerToken(self.mock_validator) + h, b, s = self.auth.create_authorization_response(self.request, bearer) + self.assertIn('error=invalid_request', h['Location']) + self.assertIsNone(b) + self.assertEqual(s, 302) diff --git a/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_refresh_token.py b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_refresh_token.py new file mode 100644 index 0000000000..2e363fef1a --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/grant_types/test_refresh_token.py @@ -0,0 +1,105 @@ +import json +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.openid.connect.core.grant_types import RefreshTokenGrant + +from __tests__.oauth2.rfc6749.grant_types.test_refresh_token import ( + RefreshTokenGrantTest, +) +from tests.unittest import TestCase + + +def get_id_token_mock(token, token_handler, request): + return "MOCKED_TOKEN" + + +class OpenIDRefreshTokenInterferenceTest(RefreshTokenGrantTest): + """Test that OpenID don't interfere with normal OAuth 2 flows.""" + + def setUp(self): + super().setUp() + self.auth = RefreshTokenGrant(request_validator=self.mock_validator) + + +class OpenIDRefreshTokenTest(TestCase): + + def setUp(self): + self.request = Request('http://a.b/path') + self.request.grant_type = 'refresh_token' + self.request.refresh_token = 'lsdkfhj230' + self.request.scope = ('hello', 'openid') + self.mock_validator = mock.MagicMock() + + self.mock_validator = mock.MagicMock() + self.mock_validator.authenticate_client.side_effect = self.set_client + self.mock_validator.get_id_token.side_effect = get_id_token_mock + self.auth = RefreshTokenGrant(request_validator=self.mock_validator) + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def test_refresh_id_token(self): + self.mock_validator.get_original_scopes.return_value = [ + 'hello', 'openid' + ] + bearer = BearerToken(self.mock_validator) + + headers, body, status_code = self.auth.create_token_response( + self.request, bearer + ) + + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('id_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertEqual(token['scope'], 'hello openid') + self.mock_validator.refresh_id_token.assert_called_once_with( + self.request + ) + + def test_refresh_id_token_false(self): + self.mock_validator.refresh_id_token.return_value = False + self.mock_validator.get_original_scopes.return_value = [ + 'hello', 'openid' + ] + bearer = BearerToken(self.mock_validator) + + headers, body, status_code = self.auth.create_token_response( + self.request, bearer + ) + + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertEqual(token['scope'], 'hello openid') + self.assertNotIn('id_token', token) + self.mock_validator.refresh_id_token.assert_called_once_with( + self.request + ) + + def test_refresh_token_without_openid_scope(self): + self.request.scope = "hello" + bearer = BearerToken(self.mock_validator) + + headers, body, status_code = self.auth.create_token_response( + self.request, bearer + ) + + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertNotIn('id_token', token) + self.assertEqual(token['scope'], 'hello') diff --git a/contrib/python/oauthlib/tests/openid/connect/core/test_request_validator.py b/contrib/python/oauthlib/tests/openid/connect/core/test_request_validator.py new file mode 100644 index 0000000000..6a800d41ca --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/test_request_validator.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from oauthlib.openid import RequestValidator + +from tests.unittest import TestCase + + +class RequestValidatorTest(TestCase): + + def test_method_contracts(self): + v = RequestValidator() + self.assertRaises( + NotImplementedError, + v.get_authorization_code_scopes, + 'client_id', 'code', 'redirect_uri', 'request' + ) + self.assertRaises( + NotImplementedError, + v.get_jwt_bearer_token, + 'token', 'token_handler', 'request' + ) + self.assertRaises( + NotImplementedError, + v.finalize_id_token, + 'id_token', 'token', 'token_handler', 'request' + ) + self.assertRaises( + NotImplementedError, + v.validate_jwt_bearer_token, + 'token', 'scopes', 'request' + ) + self.assertRaises( + NotImplementedError, + v.validate_id_token, + 'token', 'scopes', 'request' + ) + self.assertRaises( + NotImplementedError, + v.validate_silent_authorization, + 'request' + ) + self.assertRaises( + NotImplementedError, + v.validate_silent_login, + 'request' + ) + self.assertRaises( + NotImplementedError, + v.validate_user_match, + 'id_token_hint', 'scopes', 'claims', 'request' + ) diff --git a/contrib/python/oauthlib/tests/openid/connect/core/test_server.py b/contrib/python/oauthlib/tests/openid/connect/core/test_server.py new file mode 100644 index 0000000000..47f0ecc842 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/test_server.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +import json +from unittest import mock + +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.endpoints.authorization import ( + AuthorizationEndpoint, +) +from oauthlib.oauth2.rfc6749.endpoints.token import TokenEndpoint +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.openid.connect.core.grant_types.authorization_code import ( + AuthorizationCodeGrant, +) +from oauthlib.openid.connect.core.grant_types.hybrid import HybridGrant +from oauthlib.openid.connect.core.grant_types.implicit import ImplicitGrant + +from tests.unittest import TestCase + + +class AuthorizationEndpointTest(TestCase): + + def setUp(self): + self.mock_validator = mock.MagicMock() + self.mock_validator.get_code_challenge.return_value = None + self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock()) + auth_code = AuthorizationCodeGrant(request_validator=self.mock_validator) + auth_code.save_authorization_code = mock.MagicMock() + implicit = ImplicitGrant( + request_validator=self.mock_validator) + implicit.save_token = mock.MagicMock() + hybrid = HybridGrant(self.mock_validator) + + response_types = { + 'code': auth_code, + 'token': implicit, + 'id_token': implicit, + 'id_token token': implicit, + 'code token': hybrid, + 'code id_token': hybrid, + 'code token id_token': hybrid, + 'none': auth_code + } + self.expires_in = 1800 + token = BearerToken( + self.mock_validator, + expires_in=self.expires_in + ) + self.endpoint = AuthorizationEndpoint( + default_response_type='code', + default_token_type=token, + response_types=response_types + ) + + # TODO: Add hybrid grant test + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_authorization_grant(self): + uri = 'http://i.b/l?response_type=code&client_id=me&scope=all+of+them&state=xyz' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?code=abc&state=xyz') + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_implicit_grant(self): + uri = 'http://i.b/l?response_type=token&client_id=me&scope=all+of+them&state=xyz' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me#access_token=abc&expires_in=' + str(self.expires_in) + '&token_type=Bearer&state=xyz&scope=all+of+them', parse_fragment=True) + + def test_none_grant(self): + uri = 'http://i.b/l?response_type=none&client_id=me&scope=all+of+them&state=xyz' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?state=xyz', parse_fragment=True) + self.assertIsNone(body) + self.assertEqual(status_code, 302) + + # and without the state parameter + uri = 'http://i.b/l?response_type=none&client_id=me&scope=all+of+them' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me', parse_fragment=True) + self.assertIsNone(body) + self.assertEqual(status_code, 302) + + def test_missing_type(self): + uri = 'http://i.b/l?client_id=me&scope=all+of+them' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + self.mock_validator.validate_request = mock.MagicMock( + side_effect=errors.InvalidRequestError()) + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?error=invalid_request&error_description=Missing+response_type+parameter.') + + def test_invalid_type(self): + uri = 'http://i.b/l?response_type=invalid&client_id=me&scope=all+of+them' + uri += '&redirect_uri=http%3A%2F%2Fback.to%2Fme' + self.mock_validator.validate_request = mock.MagicMock( + side_effect=errors.UnsupportedResponseTypeError()) + headers, body, status_code = self.endpoint.create_authorization_response( + uri, scopes=['all', 'of', 'them']) + self.assertIn('Location', headers) + self.assertURLEqual(headers['Location'], 'http://back.to/me?error=unsupported_response_type') + + +class TokenEndpointTest(TestCase): + + def setUp(self): + def set_user(request): + request.user = mock.MagicMock() + request.client = mock.MagicMock() + request.client.client_id = 'mocked_client_id' + return True + + self.mock_validator = mock.MagicMock() + self.mock_validator.authenticate_client.side_effect = set_user + self.mock_validator.get_code_challenge.return_value = None + self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock()) + auth_code = AuthorizationCodeGrant( + request_validator=self.mock_validator) + supported_types = { + 'authorization_code': auth_code, + } + self.expires_in = 1800 + token = BearerToken( + self.mock_validator, + expires_in=self.expires_in + ) + self.endpoint = TokenEndpoint( + 'authorization_code', + default_token_type=token, + grant_types=supported_types + ) + + @mock.patch('oauthlib.common.generate_token', new=lambda: 'abc') + def test_authorization_grant(self): + body = 'grant_type=authorization_code&code=abc&scope=all+of+them' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': 'abc', + 'refresh_token': 'abc', + 'scope': 'all of them' + } + self.assertEqual(json.loads(body), token) + + body = 'grant_type=authorization_code&code=abc' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + token = { + 'token_type': 'Bearer', + 'expires_in': self.expires_in, + 'access_token': 'abc', + 'refresh_token': 'abc' + } + self.assertEqual(json.loads(body), token) + + # ignore useless fields + body = 'grant_type=authorization_code&code=abc&state=foobar' + headers, body, status_code = self.endpoint.create_token_response( + '', body=body) + self.assertEqual(json.loads(body), token) + + def test_missing_type(self): + _, body, _ = self.endpoint.create_token_response('', body='') + token = {'error': 'unsupported_grant_type'} + self.assertEqual(json.loads(body), token) + + def test_invalid_type(self): + body = 'grant_type=invalid' + _, body, _ = self.endpoint.create_token_response('', body=body) + token = {'error': 'unsupported_grant_type'} + self.assertEqual(json.loads(body), token) diff --git a/contrib/python/oauthlib/tests/openid/connect/core/test_tokens.py b/contrib/python/oauthlib/tests/openid/connect/core/test_tokens.py new file mode 100644 index 0000000000..fe90142bb8 --- /dev/null +++ b/contrib/python/oauthlib/tests/openid/connect/core/test_tokens.py @@ -0,0 +1,157 @@ +from unittest import mock + +from oauthlib.openid.connect.core.tokens import JWTToken + +from tests.unittest import TestCase + + +class JWTTokenTestCase(TestCase): + + def test_create_token_callable_expires_in(self): + """ + Test retrieval of the expires in value by calling the callable expires_in property + """ + + expires_in_mock = mock.MagicMock() + request_mock = mock.MagicMock() + + token = JWTToken(expires_in=expires_in_mock, request_validator=mock.MagicMock()) + token.create_token(request=request_mock) + + expires_in_mock.assert_called_once_with(request_mock) + + def test_create_token_non_callable_expires_in(self): + """ + When a non callable expires in is set this should just be set to the request + """ + + expires_in_mock = mock.NonCallableMagicMock() + request_mock = mock.MagicMock() + + token = JWTToken(expires_in=expires_in_mock, request_validator=mock.MagicMock()) + token.create_token(request=request_mock) + + self.assertFalse(expires_in_mock.called) + self.assertEqual(request_mock.expires_in, expires_in_mock) + + def test_create_token_calls_get_id_token(self): + """ + When create_token is called the call should be forwarded to the get_id_token on the token validator + """ + request_mock = mock.MagicMock() + + with mock.patch('oauthlib.openid.RequestValidator', + autospec=True) as RequestValidatorMock: + + request_validator = RequestValidatorMock() + + token = JWTToken(expires_in=mock.MagicMock(), request_validator=request_validator) + token.create_token(request=request_mock) + + request_validator.get_jwt_bearer_token.assert_called_once_with(None, None, request_mock) + + def test_validate_request_token_from_headers(self): + """ + Bearer token get retrieved from headers. + """ + + with mock.patch('oauthlib.common.Request', autospec=True) as RequestMock, \ + mock.patch('oauthlib.openid.RequestValidator', + autospec=True) as RequestValidatorMock: + request_validator_mock = RequestValidatorMock() + + token = JWTToken(request_validator=request_validator_mock) + + request = RequestMock('/uri') + # Scopes is retrieved using the __call__ method which is not picked up correctly by mock.patch + # with autospec=True + request.scopes = mock.MagicMock() + request.headers = { + 'Authorization': 'Bearer some-token-from-header' + } + + token.validate_request(request=request) + + request_validator_mock.validate_jwt_bearer_token.assert_called_once_with('some-token-from-header', + request.scopes, + request) + + def test_validate_request_token_from_headers_basic(self): + """ + Wrong kind of token (Basic) retrieved from headers. Confirm token is not parsed. + """ + + with mock.patch('oauthlib.common.Request', autospec=True) as RequestMock, \ + mock.patch('oauthlib.openid.RequestValidator', + autospec=True) as RequestValidatorMock: + request_validator_mock = RequestValidatorMock() + + token = JWTToken(request_validator=request_validator_mock) + + request = RequestMock('/uri') + # Scopes is retrieved using the __call__ method which is not picked up correctly by mock.patch + # with autospec=True + request.scopes = mock.MagicMock() + request.headers = { + 'Authorization': 'Basic some-token-from-header' + } + + token.validate_request(request=request) + + request_validator_mock.validate_jwt_bearer_token.assert_called_once_with(None, + request.scopes, + request) + + def test_validate_token_from_request(self): + """ + Token get retrieved from request object. + """ + + with mock.patch('oauthlib.common.Request', autospec=True) as RequestMock, \ + mock.patch('oauthlib.openid.RequestValidator', + autospec=True) as RequestValidatorMock: + request_validator_mock = RequestValidatorMock() + + token = JWTToken(request_validator=request_validator_mock) + + request = RequestMock('/uri') + # Scopes is retrieved using the __call__ method which is not picked up correctly by mock.patch + # with autospec=True + request.scopes = mock.MagicMock() + request.access_token = 'some-token-from-request-object' + request.headers = {} + + token.validate_request(request=request) + + request_validator_mock.validate_jwt_bearer_token.assert_called_once_with('some-token-from-request-object', + request.scopes, + request) + + def test_estimate_type(self): + """ + Estimate type results for a jwt token + """ + + def test_token(token, expected_result): + with mock.patch('oauthlib.common.Request', autospec=True) as RequestMock: + jwt_token = JWTToken() + + request = RequestMock('/uri') + # Scopes is retrieved using the __call__ method which is not picked up correctly by mock.patch + # with autospec=True + request.headers = { + 'Authorization': 'Bearer {}'.format(token) + } + + result = jwt_token.estimate_type(request=request) + + self.assertEqual(result, expected_result) + + test_items = ( + ('eyfoo.foo.foo', 10), + ('eyfoo.foo.foo.foo.foo', 10), + ('eyfoobar', 0) + ) + + for token, expected_result in test_items: + test_token(token, expected_result) diff --git a/contrib/python/oauthlib/tests/test_common.py b/contrib/python/oauthlib/tests/test_common.py new file mode 100644 index 0000000000..7f0e35bc9c --- /dev/null +++ b/contrib/python/oauthlib/tests/test_common.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +import oauthlib +from oauthlib.common import ( + CaseInsensitiveDict, Request, add_params_to_uri, extract_params, + generate_client_id, generate_nonce, generate_timestamp, generate_token, + urldecode, +) + +from tests.unittest import TestCase + +PARAMS_DICT = {'foo': 'bar', 'baz': '123', } +PARAMS_TWOTUPLE = [('foo', 'bar'), ('baz', '123')] +PARAMS_FORMENCODED = 'foo=bar&baz=123' +URI = 'http://www.someuri.com' + + +class EncodingTest(TestCase): + + def test_urldecode(self): + self.assertCountEqual(urldecode(''), []) + self.assertCountEqual(urldecode('='), [('', '')]) + self.assertCountEqual(urldecode('%20'), [(' ', '')]) + self.assertCountEqual(urldecode('+'), [(' ', '')]) + self.assertCountEqual(urldecode('c2'), [('c2', '')]) + self.assertCountEqual(urldecode('c2='), [('c2', '')]) + self.assertCountEqual(urldecode('foo=bar'), [('foo', 'bar')]) + self.assertCountEqual(urldecode('foo_%20~=.bar-'), + [('foo_ ~', '.bar-')]) + self.assertCountEqual(urldecode('foo=1,2,3'), [('foo', '1,2,3')]) + self.assertCountEqual(urldecode('foo=(1,2,3)'), [('foo', '(1,2,3)')]) + self.assertCountEqual(urldecode('foo=bar.*'), [('foo', 'bar.*')]) + self.assertCountEqual(urldecode('foo=bar@spam'), [('foo', 'bar@spam')]) + self.assertCountEqual(urldecode('foo=bar/baz'), [('foo', 'bar/baz')]) + self.assertCountEqual(urldecode('foo=bar?baz'), [('foo', 'bar?baz')]) + self.assertCountEqual(urldecode('foo=bar\'s'), [('foo', 'bar\'s')]) + self.assertCountEqual(urldecode('foo=$'), [('foo', '$')]) + self.assertRaises(ValueError, urldecode, 'foo bar') + self.assertRaises(ValueError, urldecode, '%R') + self.assertRaises(ValueError, urldecode, '%RA') + self.assertRaises(ValueError, urldecode, '%AR') + self.assertRaises(ValueError, urldecode, '%RR') + + +class ParameterTest(TestCase): + + def test_extract_params_dict(self): + self.assertCountEqual(extract_params(PARAMS_DICT), PARAMS_TWOTUPLE) + + def test_extract_params_twotuple(self): + self.assertCountEqual(extract_params(PARAMS_TWOTUPLE), PARAMS_TWOTUPLE) + + def test_extract_params_formencoded(self): + self.assertCountEqual(extract_params(PARAMS_FORMENCODED), + PARAMS_TWOTUPLE) + + def test_extract_params_blank_string(self): + self.assertCountEqual(extract_params(''), []) + + def test_extract_params_empty_list(self): + self.assertCountEqual(extract_params([]), []) + + def test_extract_non_formencoded_string(self): + self.assertIsNone(extract_params('not a formencoded string')) + + def test_extract_invalid(self): + self.assertIsNone(extract_params(object())) + self.assertIsNone(extract_params([('')])) + + def test_add_params_to_uri(self): + correct = '{}?{}'.format(URI, PARAMS_FORMENCODED) + self.assertURLEqual(add_params_to_uri(URI, PARAMS_DICT), correct) + self.assertURLEqual(add_params_to_uri(URI, PARAMS_TWOTUPLE), correct) + + +class GeneratorTest(TestCase): + + def test_generate_timestamp(self): + timestamp = generate_timestamp() + self.assertIsInstance(timestamp, str) + self.assertTrue(int(timestamp)) + self.assertGreater(int(timestamp), 1331672335) + + def test_generate_nonce(self): + """Ping me (ib-lundgren) when you discover how to test randomness.""" + nonce = generate_nonce() + for i in range(50): + self.assertNotEqual(nonce, generate_nonce()) + + def test_generate_token(self): + token = generate_token() + self.assertEqual(len(token), 30) + + token = generate_token(length=44) + self.assertEqual(len(token), 44) + + token = generate_token(length=6, chars="python") + self.assertEqual(len(token), 6) + for c in token: + self.assertIn(c, "python") + + def test_generate_client_id(self): + client_id = generate_client_id() + self.assertEqual(len(client_id), 30) + + client_id = generate_client_id(length=44) + self.assertEqual(len(client_id), 44) + + client_id = generate_client_id(length=6, chars="python") + self.assertEqual(len(client_id), 6) + for c in client_id: + self.assertIn(c, "python") + + +class RequestTest(TestCase): + + def test_non_unicode_params(self): + r = Request( + b'http://a.b/path?query', + http_method=b'GET', + body=b'you=shall+pass', + headers={ + b'a': b'b', + } + ) + self.assertEqual(r.uri, 'http://a.b/path?query') + self.assertEqual(r.http_method, 'GET') + self.assertEqual(r.body, 'you=shall+pass') + self.assertEqual(r.decoded_body, [('you', 'shall pass')]) + self.assertEqual(r.headers, {'a': 'b'}) + + def test_none_body(self): + r = Request(URI) + self.assertIsNone(r.decoded_body) + + def test_empty_list_body(self): + r = Request(URI, body=[]) + self.assertEqual(r.decoded_body, []) + + def test_empty_dict_body(self): + r = Request(URI, body={}) + self.assertEqual(r.decoded_body, []) + + def test_empty_string_body(self): + r = Request(URI, body='') + self.assertEqual(r.decoded_body, []) + + def test_non_formencoded_string_body(self): + body = 'foo bar' + r = Request(URI, body=body) + self.assertIsNone(r.decoded_body) + + def test_param_free_sequence_body(self): + body = [1, 1, 2, 3, 5, 8, 13] + r = Request(URI, body=body) + self.assertIsNone(r.decoded_body) + + def test_list_body(self): + r = Request(URI, body=PARAMS_TWOTUPLE) + self.assertCountEqual(r.decoded_body, PARAMS_TWOTUPLE) + + def test_dict_body(self): + r = Request(URI, body=PARAMS_DICT) + self.assertCountEqual(r.decoded_body, PARAMS_TWOTUPLE) + + def test_getattr_existing_attribute(self): + r = Request(URI, body='foo bar') + self.assertEqual('foo bar', getattr(r, 'body')) + + def test_getattr_return_default(self): + r = Request(URI, body='') + actual_value = getattr(r, 'does_not_exist', 'foo bar') + self.assertEqual('foo bar', actual_value) + + def test_getattr_raise_attribute_error(self): + r = Request(URI, body='foo bar') + with self.assertRaises(AttributeError): + getattr(r, 'does_not_exist') + + def test_sanitizing_authorization_header(self): + r = Request(URI, headers={'Accept': 'application/json', + 'Authorization': 'Basic Zm9vOmJhcg=='} + ) + self.assertNotIn('Zm9vOmJhcg==', repr(r)) + self.assertIn('<SANITIZED>', repr(r)) + # Double-check we didn't modify the underlying object: + self.assertEqual(r.headers['Authorization'], 'Basic Zm9vOmJhcg==') + + def test_token_body(self): + payload = 'client_id=foo&refresh_token=bar' + r = Request(URI, body=payload) + self.assertNotIn('bar', repr(r)) + self.assertIn('<SANITIZED>', repr(r)) + + payload = 'refresh_token=bar&client_id=foo' + r = Request(URI, body=payload) + self.assertNotIn('bar', repr(r)) + self.assertIn('<SANITIZED>', repr(r)) + + def test_password_body(self): + payload = 'username=foo&password=bar' + r = Request(URI, body=payload) + self.assertNotIn('bar', repr(r)) + self.assertIn('<SANITIZED>', repr(r)) + + payload = 'password=bar&username=foo' + r = Request(URI, body=payload) + self.assertNotIn('bar', repr(r)) + self.assertIn('<SANITIZED>', repr(r)) + + def test_headers_params(self): + r = Request(URI, headers={'token': 'foobar'}, body='token=banana') + self.assertEqual(r.headers['token'], 'foobar') + self.assertEqual(r.token, 'banana') + + def test_sanitized_request_non_debug_mode(self): + """make sure requests are sanitized when in non debug mode. + For the debug mode, the other tests checking sanitization should prove + that debug mode is working. + """ + try: + oauthlib.set_debug(False) + r = Request(URI, headers={'token': 'foobar'}, body='token=banana') + self.assertNotIn('token', repr(r)) + self.assertIn('SANITIZED', repr(r)) + finally: + # set flag back for other tests + oauthlib.set_debug(True) + + +class CaseInsensitiveDictTest(TestCase): + + def test_basic(self): + cid = CaseInsensitiveDict({}) + cid['a'] = 'b' + cid['c'] = 'd' + del cid['c'] + self.assertEqual(cid['A'], 'b') + self.assertEqual(cid['a'], 'b') + + def test_update(self): + cid = CaseInsensitiveDict({}) + cid.update({'KeY': 'value'}) + self.assertEqual(cid['kEy'], 'value') diff --git a/contrib/python/oauthlib/tests/test_uri_validate.py b/contrib/python/oauthlib/tests/test_uri_validate.py new file mode 100644 index 0000000000..6a9f8ea60b --- /dev/null +++ b/contrib/python/oauthlib/tests/test_uri_validate.py @@ -0,0 +1,84 @@ +import unittest +from oauthlib.uri_validate import is_absolute_uri + +from tests.unittest import TestCase + + +class UriValidateTest(TestCase): + + def test_is_absolute_uri(self): + self.assertIsNotNone(is_absolute_uri('schema://example.com/path')) + self.assertIsNotNone(is_absolute_uri('https://example.com/path')) + self.assertIsNotNone(is_absolute_uri('https://example.com')) + self.assertIsNotNone(is_absolute_uri('https://example.com:443/path')) + self.assertIsNotNone(is_absolute_uri('https://example.com:443/')) + self.assertIsNotNone(is_absolute_uri('https://example.com:443')) + self.assertIsNotNone(is_absolute_uri('http://example.com')) + self.assertIsNotNone(is_absolute_uri('http://example.com/path')) + self.assertIsNotNone(is_absolute_uri('http://example.com:80/path')) + + def test_query(self): + self.assertIsNotNone(is_absolute_uri('http://example.com:80/path?foo')) + self.assertIsNotNone(is_absolute_uri('http://example.com:80/path?foo=bar')) + self.assertIsNotNone(is_absolute_uri('http://example.com:80/path?foo=bar&fruit=banana')) + + def test_fragment_forbidden(self): + self.assertIsNone(is_absolute_uri('http://example.com:80/path#foo')) + self.assertIsNone(is_absolute_uri('http://example.com:80/path#foo=bar')) + self.assertIsNone(is_absolute_uri('http://example.com:80/path#foo=bar&fruit=banana')) + + def test_combined_forbidden(self): + self.assertIsNone(is_absolute_uri('http://example.com:80/path?foo#bar')) + self.assertIsNone(is_absolute_uri('http://example.com:80/path?foo&bar#fruit')) + self.assertIsNone(is_absolute_uri('http://example.com:80/path?foo=1&bar#fruit=banana')) + self.assertIsNone(is_absolute_uri('http://example.com:80/path?foo=1&bar=2#fruit=banana&bar=foo')) + + def test_custom_scheme(self): + self.assertIsNotNone(is_absolute_uri('com.example.bundle.id://')) + + def test_ipv6_bracket(self): + self.assertIsNotNone(is_absolute_uri('http://[::1]:38432/path')) + self.assertIsNotNone(is_absolute_uri('http://[::1]/path')) + self.assertIsNotNone(is_absolute_uri('http://[fd01:0001::1]/path')) + self.assertIsNotNone(is_absolute_uri('http://[fd01:1::1]/path')) + self.assertIsNotNone(is_absolute_uri('http://[0123:4567:89ab:cdef:0123:4567:89ab:cdef]/path')) + self.assertIsNotNone(is_absolute_uri('http://[0123:4567:89ab:cdef:0123:4567:89ab:cdef]:8080/path')) + + @unittest.skip("ipv6 edge-cases not supported") + def test_ipv6_edge_cases(self): + self.assertIsNotNone(is_absolute_uri('http://2001:db8::')) + self.assertIsNotNone(is_absolute_uri('http://::1234:5678')) + self.assertIsNotNone(is_absolute_uri('http://2001:db8::1234:5678')) + self.assertIsNotNone(is_absolute_uri('http://2001:db8:3333:4444:5555:6666:7777:8888')) + self.assertIsNotNone(is_absolute_uri('http://2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF')) + self.assertIsNotNone(is_absolute_uri('http://0123:4567:89ab:cdef:0123:4567:89ab:cdef/path')) + self.assertIsNotNone(is_absolute_uri('http://::')) + self.assertIsNotNone(is_absolute_uri('http://2001:0db8:0001:0000:0000:0ab9:C0A8:0102')) + + @unittest.skip("ipv6 dual ipv4 not supported") + def test_ipv6_dual(self): + self.assertIsNotNone(is_absolute_uri('http://2001:db8:3333:4444:5555:6666:1.2.3.4')) + self.assertIsNotNone(is_absolute_uri('http://::11.22.33.44')) + self.assertIsNotNone(is_absolute_uri('http://2001:db8::123.123.123.123')) + self.assertIsNotNone(is_absolute_uri('http://::1234:5678:91.123.4.56')) + self.assertIsNotNone(is_absolute_uri('http://::1234:5678:1.2.3.4')) + self.assertIsNotNone(is_absolute_uri('http://2001:db8::1234:5678:5.6.7.8')) + + def test_ipv4(self): + self.assertIsNotNone(is_absolute_uri('http://127.0.0.1:38432/')) + self.assertIsNotNone(is_absolute_uri('http://127.0.0.1:38432/')) + self.assertIsNotNone(is_absolute_uri('http://127.1:38432/')) + + def test_failures(self): + self.assertIsNone(is_absolute_uri('http://example.com:notaport/path')) + self.assertIsNone(is_absolute_uri('wrong')) + self.assertIsNone(is_absolute_uri('http://[:1]:38432/path')) + self.assertIsNone(is_absolute_uri('http://[abcd:efgh::1]/')) + + def test_recursive_regex(self): + from datetime import datetime + t0 = datetime.now() + is_absolute_uri('http://[::::::::::::::::::::::::::]/path') + t1 = datetime.now() + spent = t1 - t0 + self.assertGreater(0.1, spent.total_seconds(), "possible recursive loop detected") diff --git a/contrib/python/oauthlib/tests/unittest/__init__.py b/contrib/python/oauthlib/tests/unittest/__init__.py new file mode 100644 index 0000000000..f94f35c664 --- /dev/null +++ b/contrib/python/oauthlib/tests/unittest/__init__.py @@ -0,0 +1,32 @@ +import urllib.parse as urlparse +from unittest import TestCase + + +# URL comparison where query param order is insignificant +def url_equals(self, a, b, parse_fragment=False): + parsed_a = urlparse.urlparse(a, allow_fragments=parse_fragment) + parsed_b = urlparse.urlparse(b, allow_fragments=parse_fragment) + query_a = urlparse.parse_qsl(parsed_a.query) + query_b = urlparse.parse_qsl(parsed_b.query) + if parse_fragment: + fragment_a = urlparse.parse_qsl(parsed_a.fragment) + fragment_b = urlparse.parse_qsl(parsed_b.fragment) + self.assertCountEqual(fragment_a, fragment_b) + else: + self.assertEqual(parsed_a.fragment, parsed_b.fragment) + self.assertEqual(parsed_a.scheme, parsed_b.scheme) + self.assertEqual(parsed_a.netloc, parsed_b.netloc) + self.assertEqual(parsed_a.path, parsed_b.path) + self.assertEqual(parsed_a.params, parsed_b.params) + self.assertEqual(parsed_a.username, parsed_b.username) + self.assertEqual(parsed_a.password, parsed_b.password) + self.assertEqual(parsed_a.hostname, parsed_b.hostname) + self.assertEqual(parsed_a.port, parsed_b.port) + self.assertCountEqual(query_a, query_b) + + +TestCase.assertURLEqual = url_equals + +# Form body comparison where order is insignificant +TestCase.assertFormBodyEqual = lambda self, a, b: self.assertCountEqual( + urlparse.parse_qsl(a), urlparse.parse_qsl(b)) diff --git a/contrib/python/oauthlib/tests/ya.make b/contrib/python/oauthlib/tests/ya.make new file mode 100644 index 0000000000..b207e5ea63 --- /dev/null +++ b/contrib/python/oauthlib/tests/ya.make @@ -0,0 +1,88 @@ +PY3TEST() + +PEERDIR( + contrib/python/oauthlib + contrib/python/mock + contrib/python/PyJWT + contrib/python/blinker +) + +PY_SRCS( + NAMESPACE tests + unittest/__init__.py +) + +TEST_SRCS( + __init__.py + oauth1/__init__.py + oauth1/rfc5849/__init__.py + oauth1/rfc5849/endpoints/__init__.py + oauth1/rfc5849/endpoints/test_access_token.py + oauth1/rfc5849/endpoints/test_authorization.py + oauth1/rfc5849/endpoints/test_base.py + oauth1/rfc5849/endpoints/test_request_token.py + oauth1/rfc5849/endpoints/test_resource.py + oauth1/rfc5849/endpoints/test_signature_only.py + oauth1/rfc5849/test_client.py + oauth1/rfc5849/test_parameters.py + oauth1/rfc5849/test_request_validator.py + oauth1/rfc5849/test_signatures.py + oauth1/rfc5849/test_utils.py + oauth2/__init__.py + oauth2/rfc6749/__init__.py + oauth2/rfc6749/clients/__init__.py + oauth2/rfc6749/clients/test_backend_application.py + oauth2/rfc6749/clients/test_base.py + oauth2/rfc6749/clients/test_legacy_application.py + oauth2/rfc6749/clients/test_mobile_application.py + oauth2/rfc6749/clients/test_service_application.py + oauth2/rfc6749/clients/test_web_application.py + oauth2/rfc6749/endpoints/__init__.py + oauth2/rfc6749/endpoints/test_base_endpoint.py + oauth2/rfc6749/endpoints/test_client_authentication.py + oauth2/rfc6749/endpoints/test_credentials_preservation.py + oauth2/rfc6749/endpoints/test_error_responses.py + oauth2/rfc6749/endpoints/test_extra_credentials.py + oauth2/rfc6749/endpoints/test_introspect_endpoint.py + oauth2/rfc6749/endpoints/test_metadata.py + oauth2/rfc6749/endpoints/test_resource_owner_association.py + oauth2/rfc6749/endpoints/test_revocation_endpoint.py + oauth2/rfc6749/endpoints/test_scope_handling.py + oauth2/rfc6749/endpoints/test_utils.py + oauth2/rfc6749/grant_types/__init__.py + oauth2/rfc6749/grant_types/test_authorization_code.py + oauth2/rfc6749/grant_types/test_client_credentials.py + oauth2/rfc6749/grant_types/test_implicit.py + oauth2/rfc6749/grant_types/test_refresh_token.py + oauth2/rfc6749/grant_types/test_resource_owner_password.py + oauth2/rfc6749/test_parameters.py + oauth2/rfc6749/test_request_validator.py + oauth2/rfc6749/test_server.py + oauth2/rfc6749/test_tokens.py + oauth2/rfc6749/test_utils.py + oauth2/rfc8628/__init__.py + oauth2/rfc8628/clients/__init__.py + oauth2/rfc8628/clients/test_device.py + openid/__init__.py + openid/connect/__init__.py + openid/connect/core/__init__.py + openid/connect/core/endpoints/__init__.py + openid/connect/core/endpoints/test_claims_handling.py + openid/connect/core/endpoints/test_openid_connect_params_handling.py + openid/connect/core/endpoints/test_userinfo_endpoint.py + openid/connect/core/grant_types/__init__.py + openid/connect/core/grant_types/test_authorization_code.py + openid/connect/core/grant_types/test_base.py + openid/connect/core/grant_types/test_dispatchers.py + openid/connect/core/grant_types/test_hybrid.py + openid/connect/core/grant_types/test_implicit.py + openid/connect/core/test_request_validator.py + openid/connect/core/test_server.py + openid/connect/core/test_tokens.py + test_common.py + test_uri_validate.py +) + +NO_LINT() + +END() diff --git a/contrib/python/oauthlib/ya.make b/contrib/python/oauthlib/ya.make new file mode 100644 index 0000000000..31a9686ead --- /dev/null +++ b/contrib/python/oauthlib/ya.make @@ -0,0 +1,93 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(3.2.2) + +LICENSE(BSD-3-Clause) + +NO_LINT() + +PY_SRCS( + TOP_LEVEL + oauthlib/__init__.py + oauthlib/common.py + oauthlib/oauth1/__init__.py + oauthlib/oauth1/rfc5849/__init__.py + oauthlib/oauth1/rfc5849/endpoints/__init__.py + oauthlib/oauth1/rfc5849/endpoints/access_token.py + oauthlib/oauth1/rfc5849/endpoints/authorization.py + oauthlib/oauth1/rfc5849/endpoints/base.py + oauthlib/oauth1/rfc5849/endpoints/pre_configured.py + oauthlib/oauth1/rfc5849/endpoints/request_token.py + oauthlib/oauth1/rfc5849/endpoints/resource.py + oauthlib/oauth1/rfc5849/endpoints/signature_only.py + oauthlib/oauth1/rfc5849/errors.py + oauthlib/oauth1/rfc5849/parameters.py + oauthlib/oauth1/rfc5849/request_validator.py + oauthlib/oauth1/rfc5849/signature.py + oauthlib/oauth1/rfc5849/utils.py + oauthlib/oauth2/__init__.py + oauthlib/oauth2/rfc6749/__init__.py + oauthlib/oauth2/rfc6749/clients/__init__.py + oauthlib/oauth2/rfc6749/clients/backend_application.py + oauthlib/oauth2/rfc6749/clients/base.py + oauthlib/oauth2/rfc6749/clients/legacy_application.py + oauthlib/oauth2/rfc6749/clients/mobile_application.py + oauthlib/oauth2/rfc6749/clients/service_application.py + oauthlib/oauth2/rfc6749/clients/web_application.py + oauthlib/oauth2/rfc6749/endpoints/__init__.py + oauthlib/oauth2/rfc6749/endpoints/authorization.py + oauthlib/oauth2/rfc6749/endpoints/base.py + oauthlib/oauth2/rfc6749/endpoints/introspect.py + oauthlib/oauth2/rfc6749/endpoints/metadata.py + oauthlib/oauth2/rfc6749/endpoints/pre_configured.py + oauthlib/oauth2/rfc6749/endpoints/resource.py + oauthlib/oauth2/rfc6749/endpoints/revocation.py + oauthlib/oauth2/rfc6749/endpoints/token.py + oauthlib/oauth2/rfc6749/errors.py + oauthlib/oauth2/rfc6749/grant_types/__init__.py + oauthlib/oauth2/rfc6749/grant_types/authorization_code.py + oauthlib/oauth2/rfc6749/grant_types/base.py + oauthlib/oauth2/rfc6749/grant_types/client_credentials.py + oauthlib/oauth2/rfc6749/grant_types/implicit.py + oauthlib/oauth2/rfc6749/grant_types/refresh_token.py + oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py + oauthlib/oauth2/rfc6749/parameters.py + oauthlib/oauth2/rfc6749/request_validator.py + oauthlib/oauth2/rfc6749/tokens.py + oauthlib/oauth2/rfc6749/utils.py + oauthlib/oauth2/rfc8628/__init__.py + oauthlib/oauth2/rfc8628/clients/__init__.py + oauthlib/oauth2/rfc8628/clients/device.py + oauthlib/openid/__init__.py + oauthlib/openid/connect/__init__.py + oauthlib/openid/connect/core/__init__.py + oauthlib/openid/connect/core/endpoints/__init__.py + oauthlib/openid/connect/core/endpoints/pre_configured.py + oauthlib/openid/connect/core/endpoints/userinfo.py + oauthlib/openid/connect/core/exceptions.py + oauthlib/openid/connect/core/grant_types/__init__.py + oauthlib/openid/connect/core/grant_types/authorization_code.py + oauthlib/openid/connect/core/grant_types/base.py + oauthlib/openid/connect/core/grant_types/dispatchers.py + oauthlib/openid/connect/core/grant_types/hybrid.py + oauthlib/openid/connect/core/grant_types/implicit.py + oauthlib/openid/connect/core/grant_types/refresh_token.py + oauthlib/openid/connect/core/request_validator.py + oauthlib/openid/connect/core/tokens.py + oauthlib/signals.py + oauthlib/uri_validate.py +) + +RESOURCE_FILES( + PREFIX contrib/python/oauthlib/ + .dist-info/METADATA + .dist-info/top_level.txt +) + +END() + +RECURSE_FOR_TESTS( + tests +) |