From df5deaf865f78fcf0cf346b0ff23dfe052422d5d Mon Sep 17 00:00:00 2001 From: afabiani Date: Mon, 10 May 2021 12:36:01 +0200 Subject: [PATCH] - Bump to version = 2.2.0 --- .gitignore | 1 + .travis.yml | 59 - AUTHORS | 35 +- CHANGELOG.md | 77 +- README.rst | 27 +- docs/Makefile | 3 + .../_images/application-authorize-web-app.png | Bin 0 -> 17399 bytes .../application-register-auth-code.png | Bin 0 -> 37074 bytes ...application-register-client-credential.png | Bin 0 -> 33524 bytes docs/conf.py | 152 ++- docs/contributing.rst | 148 +- docs/getting_started.rst | 394 ++++++ docs/index.rst | 12 +- docs/install.rst | 16 +- docs/models.rst | 4 +- docs/oidc.rst | 308 +++++ docs/requirements.txt | 2 +- docs/settings.rst | 115 +- docs/templates.rst | 2 +- docs/tutorial/tutorial_01.rst | 7 - docs/tutorial/tutorial_02.rst | 6 +- docs/tutorial/tutorial_03.rst | 8 - oauth2_provider/admin.py | 56 +- oauth2_provider/backends.py | 2 +- .../contrib/rest_framework/__init__.py | 7 +- .../contrib/rest_framework/authentication.py | 14 +- .../contrib/rest_framework/permissions.py | 43 +- oauth2_provider/decorators.py | 7 +- oauth2_provider/exceptions.py | 2 + oauth2_provider/forms.py | 1 + oauth2_provider/generators.py | 3 +- oauth2_provider/http.py | 5 +- .../locale/pt/LC_MESSAGES/django.po | 167 +++ .../management/commands/createapplication.py | 13 +- oauth2_provider/middleware.py | 10 +- oauth2_provider/migrations/0001_initial.py | 2 +- .../migrations/0014_auto_20210510_0935.py | 43 + oauth2_provider/models.py | 273 ++-- oauth2_provider/oauth2_backends.py | 77 +- oauth2_provider/oauth2_validators.py | 411 +++--- oauth2_provider/scopes.py | 2 +- oauth2_provider/settings.py | 150 ++- oauth2_provider/signals.py | 2 +- .../application_confirm_delete.html | 2 +- .../oauth2_provider/application_form.html | 2 +- .../oauth2_provider/application_list.html | 3 +- .../templates/oauth2_provider/authorize.html | 6 +- .../authorized-token-delete.html | 2 +- .../oauth2_provider/authorized-tokens.html | 2 +- oauth2_provider/urls.py | 40 +- oauth2_provider/validators.py | 23 +- oauth2_provider/views/__init__.py | 16 +- oauth2_provider/views/application.py | 32 +- oauth2_provider/views/base.py | 97 +- oauth2_provider/views/generic.py | 32 +- oauth2_provider/views/introspect.py | 25 +- oauth2_provider/views/mixins.py | 106 +- oauth2_provider/views/oidc.py | 93 +- oauth2_provider/views/token.py | 6 +- setup.cfg | 17 +- tests/admin.py | 21 + tests/conftest.py | 156 +++ tests/migrations/0001_initial.py | 2 + tests/models.py | 22 +- tests/presets.py | 45 + tests/settings.py | 31 +- tests/test_application_views.py | 20 +- tests/test_auth_backends.py | 45 +- tests/test_authorization_code.py | 1191 +++++++---------- tests/test_client_credential.py | 16 +- tests/test_commands.py | 1 - tests/test_decorators.py | 7 +- tests/test_generator.py | 20 +- tests/test_hybrid.py | 479 ++++--- tests/test_implicit.py | 94 +- tests/test_introspection_auth.py | 91 +- tests/test_introspection_view.py | 248 ++-- tests/test_mixins.py | 90 +- tests/test_models.py | 247 ++-- tests/test_oauth2_backends.py | 59 +- tests/test_oauth2_validators.py | 226 +++- tests/test_oidc_views.py | 122 +- tests/test_password.py | 10 +- tests/test_rest_framework.py | 58 +- tests/test_scopes.py | 52 +- tests/test_scopes_backend.py | 4 +- tests/test_settings.py | 169 +++ tests/test_token_revocation.py | 70 +- tests/test_token_view.py | 48 +- tests/test_validators.py | 5 +- tests/urls.py | 8 +- tests/utils.py | 17 + tox.ini | 125 +- 93 files changed, 4707 insertions(+), 2262 deletions(-) delete mode 100644 .travis.yml create mode 100644 docs/_images/application-authorize-web-app.png create mode 100644 docs/_images/application-register-auth-code.png create mode 100644 docs/_images/application-register-client-credential.png create mode 100644 docs/getting_started.rst create mode 100644 docs/oidc.rst create mode 100644 oauth2_provider/locale/pt/LC_MESSAGES/django.po create mode 100644 oauth2_provider/migrations/0014_auto_20210510_0935.py create mode 100644 tests/admin.py create mode 100644 tests/conftest.py create mode 100644 tests/presets.py create mode 100644 tests/test_settings.py diff --git a/.gitignore b/.gitignore index c22ef00..3643335 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ __pycache__ pip-log.txt # Unit test / coverage reports +.cache .pytest_cache .coverage .tox diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2aef56d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,59 +0,0 @@ -# https://travis-ci.org/jazzband/django-oauth-toolkit -dist: bionic - -language: python - -cache: - directories: - - $HOME/.cache/pip - - $TRAVIS_BUILD_DIR/.tox - -# Make sure to coordinate changes to envlist in tox.ini. -matrix: - allow_failures: - - env: TOXENV=py36-djangomaster - - env: TOXENV=py37-djangomaster - - env: TOXENV=py38-djangomaster - - include: - - python: 3.7 - env: TOXENV=py37-flake8 - - python: 3.7 - env: TOXENV=py37-docs - - - python: 3.8 - env: TOXENV=py38-django30 - - python: 3.8 - env: TOXENV=py38-django22 - - python: 3.8 - env: TOXENV=py38-django21 - - python: 3.8 - env: TOXENV=py38-djangomaster - - - python: 3.7 - env: TOXENV=py37-django30 - - python: 3.7 - env: TOXENV=py37-django22 - - python: 3.7 - env: TOXENV=py37-django21 - - python: 3.7 - env: TOXENV=py37-djangomaster - - - python: 3.6 - env: TOXENV=py36-django22 - - python: 3.6 - env: TOXENV=py36-django21 - - - python: 3.5 - env: TOXENV=py35-django22 - - python: 3.5 - env: TOXENV=py35-django21 - -install: - - pip install coveralls tox tox-travis - -script: - - tox - -after_script: - - coveralls diff --git a/AUTHORS b/AUTHORS index cbcefa2..5058928 100644 --- a/AUTHORS +++ b/AUTHORS @@ -7,23 +7,50 @@ Federico Frenguelli Contributors ============ -Alessandro De Angelis +Abhishek Patel Alan Crosswell -Asif Saif Uddin -Ash Christopher +Aleksander Vaskevich +Alessandro De Angelis +Allisson Azevedo +Anvesh Agarwal Aristóbulo Meneses +Aryan Iyappan +Ash Christopher +Asif Saif Uddin Bart Merenda Bas van Oostveen +Dave Burkholder David Fischer +David Smith Diego Garcia +Dulmandakh Sukhbaatar +Dylan Giesler Emanuele Palazzetti Federico Dolce +Frederico Vieira +Hasan Ramezani Hiroki Kiyohara Jens Timmerman Jerome Leclanche Jim Graham +Jonas Nygaard Pedersen +Jonathan Steffan +Jun Zhou +Kristian Rune Larsen +Paul Dekkers Paul Oswald -pySilver +Pavel Tvrdík Rodney Richardson +Rustem Saiargaliev +Sandro Rodrigues +Shaun Stanworth Silvano Cerza +Spencer Carroll Stéphane Raimbault +Tom Evans +Will Beaufoy +Rustem Saiargaliev +Jadiel Teófilo +pySilver +Łukasz Skarżyński +Shaheed Haque diff --git a/CHANGELOG.md b/CHANGELOG.md index 400bc13..52876d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [1.3.1] unreleased +## [unreleased] +* Remove support for Django 3.0 +* Add support for Django 3.2 + +### Added +* #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` + to provide compatibility with backends that need one. + +### Fixed +* #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. +* #955 Avoid doubling of `oauth2_provider` urls mountpath in json response for OIDC view `ConnectDiscoveryInfoView`. + Breaks existing OIDC discovery output +* #953 Allow loopback redirect URIs with random ports using http scheme, localhost address and no explicit port + configuration in the allowed redirect_uris for Oauth2 Applications (RFC8252) + +## [2.2.0] 2021-05-10 +Aligned to [django-oauth-toolkit 1.5.0](https://github.com/jazzband/django-oauth-toolkit/pull/947) + +### Added +* #915 Add optional OpenID Connect support. + +### Changed +* #942 Help via defunct Google group replaced with using GitHub issues + +## [2.1.1] 2021-03-12 + +### Changed +* #925 OAuth2TokenMiddleware converted to new style middleware, and no longer extends MiddlewareMixin. + +### Removed +* #936 Remove support for Python 3.5 + +## [2.1.0] 2021-02-08 + +### Added +* #917 Documentation improvement for Access Token expiration. +* #916 (for DOT contributors) Added `tox -e livedocs` which launches a local web server on `locahost:8000` + to display Sphinx documentation with live updates as you edit. +* #891 (for DOT contributors) Added [details](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) + on how best to contribute to this project. +* #884 Added support for Python 3.9 +* #898 Added the ability to customize classes for django admin +* #690 Added pt-PT translations to HTML templates. This enables adding additional translations. + +### Fixed +* #906 Made token revocation not apply a limit to the `select_for_update` statement (impacts Oracle 12c database). +* #903 Disable `redirect_uri` field length limit for `AbstractGrant` + +## [1.3.3] 2020-10-16 + +### Added +* added `select_related` in intospect view for better query performance +* #831 Authorization token creation now can receive an expire date +* #831 Added a method to override Grant creation +* #825 Bump oauthlib to 3.1.0 to introduce PKCE +* Support for Django 3.1 + +### Fixed +* #847: Fix inappropriate message when response from authentication server is not OK. + +### Changed +* few smaller improvements to remove older django version compatibility #830, #861, #862, #863 + +## [1.3.2] 2020-03-24 + +### Fixed +* Fixes: 1.3.1 inadvertently uploaded to pypi with an extra migration (0003...) from a dev branch. + +## [1.3.1] 2020-03-23 + +### Added +* #725: HTTP Basic Auth support for introspection (Fix issue #709) + ### Fixed * #812: Reverts #643 pass wrong request object to authenticate function. * Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810)) * #817: Reverts #734 tutorial documentation error. + ## [1.3.0] 2020-03-02 ### Added diff --git a/README.rst b/README.rst index c547364..fe43598 100644 --- a/README.rst +++ b/README.rst @@ -7,17 +7,24 @@ Django OAuth Toolkit *OAuth2 goodies for the Djangonauts!* -.. image:: https://badge.fury.io/py/django-oauth-toolkit.png +.. image:: https://badge.fury.io/py/django-oauth-toolkit.svg :target: http://badge.fury.io/py/django-oauth-toolkit -.. image:: https://travis-ci.org/jazzband/django-oauth-toolkit.png - :alt: Build Status - :target: https://travis-ci.org/jazzband/django-oauth-toolkit +.. image:: https://github.com/jazzband/django-oauth-toolkit/workflows/Test/badge.svg + :target: https://github.com/jazzband/django-oauth-toolkit/actions + :alt: GitHub Actions -.. image:: https://coveralls.io/repos/github/jazzband/django-oauth-toolkit/badge.svg?branch=master - :alt: Coverage Status - :target: https://coveralls.io/github/jazzband/django-oauth-toolkit?branch=master +.. image:: https://codecov.io/gh/jazzband/django-oauth-toolkit/branch/master/graph/badge.svg + :target: https://codecov.io/gh/jazzband/django-oauth-toolkit + :alt: Coverage +.. image:: https://img.shields.io/pypi/pyversions/django-oauth-toolkit.svg + :target: https://pypi.org/project/django-oauth-toolkit/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/djversions/django-oauth-toolkit.svg + :target: https://pypi.org/project/django-oauth-toolkit/ + :alt: Supported Django versions If you are facing one or more of the following: * Your Django app exposes a web API you want to protect with OAuth2 authentication, @@ -42,9 +49,9 @@ Please report any security issues to the JazzBand security team at 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ΜZW{a&l8&OmuYgg9lod zFAMCWKs%1#;ENuLA#@RW4jRu)*W1u97SR*Am)?H*{F#rPy+67uNSW&a)NhpIt@RNZ z@?;!z^xSm0)>c*$;^HeSE7%*K+7G+u=H`Hsz|XCWK}_h#HWah%DuAcJXFLUhu$B?w6k_`gn=jhntTqj!&``o%wgO#Pf=aC^Rdf zJ{J@`R2o!wMKg|bb?A&2%@Inn9qF-hoM_f0Az9hjOuo6+C|z==dIKuR&B*xL-o7zc$9DbmLS>bJMUM<(vuv%_-?EP1_eE+lL0z}K1@qM zW&b-<={Gk>N+kEYo0FxWZ=@)INm2B!F0uO5bu# zWQ;t&JUInk5(_HnG+d8*2c;yU(HpoD9WFHM<9azoB_&PJ>v7s3^3~;nNsW(>$CgMR zJ7$G&t`Xc1)&g}Hpm=$vkH|(B7ZcwsJ;|3-G7ujHAOZ#GUx1F&z86bKOAuk60xSc( z0rCMt*|U51RpGcIR{Zl|vA)5fSRpPA^=zA0AE>QPVaFHv|2C?oVAxrr#kApKQNcF;c8 z8bj{k=jW%VqvPb{q}a1Z(6}Uk<_Q1`4!oUR4#?w5zskbG!dtht5$-ma2L%K?#aRaM zfecPTMfE~!Ypb*S`*-m(XMh5N^jAPR&UrkEilU8eEcaS`sn1s{2)9wGP6T~cW@ZCS z29i1`E07Y07FJh|O?SAsIB6Of6l2*TS*=*zym=G;CU`0k*pMm>UHQQ1LT-mmDifBE z(NQHzAJ{_BIqYQ-<>TSm#W6fKrtBMwm`0$^z3;-o{fB8Hsf08HdiB@9z%805LRUWk zo}sIL^!Tyml`H*&gSRI@+@s{E#*4};C}3C6mPhN)gOaHWV?hJc{eD*<4Z%h7V4qTE zDuoC$znBoQidlPebl)dvq%YJGAV1YxA0#(?@?zkRz+^XbzkVIq_$WVV59N>IQ~PDgygSvb&5bRCMJEgwZD;91s$ib=yU)>5wS>jsX zzfZaT(C0j5JuQ>aLm3ffRn91v@=YwKlao{Z^_R2y46EA+N@&H|I8TF;0p%h3Aw+(H~e`v<1BHWZ)>={l3Ao~A|oEz>sD%|(tR(rz^8 z&Uy6wzLD|rWmk#o@k3S5wK7qGYBUwN=&&6pnC`=+=(Ot(k|$0bzP3@G=|?Q9VZy!6 zmX-rJa_C}-u0*hOD6D^1R|jH8T}n`h@hB`@#_2_xMp(?L05Eu1Mt!kZ9&M@a>#Hm( z0tLs+0jq&80ugNkD3EvbhC-%_dHQ^)+rYW6e@tTcKgWwvb3D(@^_%KC1 M9vuUN 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 z zj3$m%XlZ?#Hr+d8ugnN(b~$G_qk}%3R|PD_c+xd5wAWN;F!e`|E|X%Y3?%t#v7xSa zgT9>{H$v;l)5PcmwQZxVADpr)NQkNW{bY#lw`+?W4xY>`n~~WNZS5La-`s8;l6Z%s z=Xy%}J=kz%r6eHDyW?fu*4deJJkqutxUzm)V4>z2?=R^;D(*H1yD3gS(dJJ%K2iud zaxEdBb+q;zxPGX9?*ESv&cz)LSX&cSeJ|o5Bd^UiM*U#pk(!U;1QU<6ZGF&}>VVq^ zKJt|A+7VjKQ$CiB{`J!hQ+}d}|Dx_acw!(XE$@fhCR-ryHHzW;J+u`e6JJ^RDJp~` zmBWnVidZgvj))&`+Aa7R>h~ZrCEs?20BtzgaRtW9I&Xb(Tm^}OW8tQse>*pN#{jO}$yssVMh7+fC0yH~ hO!HHn5C^x<&E1_-E4{(#I)5p(c;V6o3EDQl|1YivA432D literal 0 HcmV?d00001 diff --git a/docs/_images/application-register-client-credential.png b/docs/_images/application-register-client-credential.png new file mode 100644 index 0000000000000000000000000000000000000000..7f2d1cb606fba721441abc76bd6c6e291a787882 GIT binary patch literal 33524 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 v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -#html_static_path = ['_static'] +# html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'DjangoOAuthToolkitdoc' +htmlhelp_basename = "DjangoOAuthToolkitdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'DjangoOAuthToolkit.tex', u'Django OAuth Toolkit Documentation', - u'Evonove', 'manual'), + ("index", "DjangoOAuthToolkit.tex", "Django OAuth Toolkit Documentation", "Evonove", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'djangooauthtoolkit', u'Django OAuth Toolkit Documentation', - [u'Evonove'], 1) -] +man_pages = [("index", "djangooauthtoolkit", "Django OAuth Toolkit Documentation", ["Evonove"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -249,19 +251,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'DjangoOAuthToolkit', u'Django OAuth Toolkit Documentation', - u'Evonove', 'DjangoOAuthToolkit', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "DjangoOAuthToolkit", + "Django OAuth Toolkit Documentation", + "Evonove", + "DjangoOAuthToolkit", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docs/contributing.rst b/docs/contributing.rst index 021895e..c336d04 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -2,6 +2,13 @@ Contributing ============ +.. image:: https://jazzband.co/static/img/jazzband.svg + :target: https://jazzband.co/ + :alt: Jazzband + +This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. + + Setup ===== @@ -17,6 +24,78 @@ You can find the list of bugs, enhancements and feature requests on the `issue tracker `_. If you want to fix an issue, pick up one and add a comment stating you're working on it. +Code Style +========== + +The project uses `flake8 `_ for linting, +`black `_ for formatting the code, +`isort `_ for formatting and sorting imports, +and `pre-commit `_ for checking/fixing commits for +correctness before they are made. + +You will need to install ``pre-commit`` yourself, and then ``pre-commit`` will +take care of installing ``flake8``, ``black`` and ``isort``. + +After cloning your repository, go into it and run:: + + pre-commit install + +to install the hooks. On the next commit that you make, ``pre-commit`` will +download and install the necessary hooks (a one off task). If anything in the +commit would fail the hooks, the commit will be abandoned. For ``black`` and +``isort``, any necessary changes will be made automatically, but not staged. +Review the changes, and then re-stage and commit again. + +Using ``pre-commit`` ensures that code that would fail in QA does not make it +into a commit in the first place, and will save you time in the long run. You +can also (largely) stop worrying about code style, although you should always +check how the code looks after ``black`` has formatted it, and think if there +is a better way to structure the code so that it is more readable. + +Documentation +============= + +You can edit the documentation by editing files in ``docs/``. This project +uses sphinx to turn ``ReStructuredText`` into the HTML docs you are reading. + +In order to build the docs in to HTML, you can run:: + + tox -e docs + +This will build the docs, and place the result in ``docs/_build/html``. +Alternatively, you can run:: + + tox -e livedocs + +This will run ``sphinx`` in a live reload mode, so any changes that you make to +the ``RST`` files will be automatically detected and the HTML files rebuilt. +It will also run a simple HTTP server available at ``_ +serving the HTML files, and auto-reload the page when changes are made. + +This allows you to edit the docs and see your changes instantly reflected in +the browser. + +* `ReStructuredText primer + `_ + +Translations +============ + +You can contribute international language translations using +`django-admin makemessages `_. + +For example, to add Deutsch:: + + cd oauth2_provider + django-admin makemessages --locale de + +Then edit ``locale/de/LC_MESSAGES/django.po`` to add your translations. + +When deploying your app, don't forget to compile the messages with:: + + django-admin compilemessages + + Pull requests ============= @@ -49,7 +128,7 @@ When you begin your PR, you'll be asked to provide the following: * Any new or changed code requires that a unit test be added or updated. Make sure your tests check for correct error behavior as well as normal expected behavior. Strive for 100% code coverage of any new code you contribute! Improving unit tests is always a welcome contribution. - If your change reduces coverage, you'll be warned by `coveralls `_. + If your change reduces coverage, you'll be warned by `Codecov `_. * Update the documentation (in `docs/`) to describe the new or changed functionality. @@ -70,7 +149,7 @@ When you begin your PR, you'll be asked to provide the following: JazzBand security team ``. Do not file an issue on the tracker or submit a PR until directed to do so.) -* Make sure your name is in `AUTHORS`. +* Make sure your name is in `AUTHORS`. We want to give credit to all contrbutors! If your PR is not yet ready to be merged mark it as a Work-in-Progress By prepending `WIP:` to the PR title so that it doesn't get inadvertently approved and merged. @@ -106,6 +185,29 @@ How to get your pull request accepted We really want your code, so please follow these simple guidelines to make the process as smooth as possible. +The Checklist +------------- + +A checklist template is automatically added to your PR when you create it. Make sure you've done all the +applicable steps and check them off to indicate you have done so. This is +what you'll see when creating your PR: + + Fixes # + + ## Description of the Change + + ## Checklist + + - [ ] PR only contains one change (considered splitting up PR) + - [ ] unit-test added + - [ ] documentation updated + - [ ] `CHANGELOG.md` updated (only for user relevant changes) + - [ ] author name in `AUTHORS` + +Any PRs that are missing checklist items will not be merged and may be reverted if they are merged by +mistake. + + Run the tests! -------------- @@ -132,7 +234,7 @@ You can check your coverage locally with the `coverage `_ + +Maintainer Checklist +==================== +The following notes are to remind the project maintainers and leads of the steps required to +review and merge PRs and to publish a new release. + +Reviewing and Merging PRs +------------------------ + +- Make sure the PR description includes the `pull request template + `_ +- Confirm that all required checklist items from the PR template are both indicated as done in the + PR description and are actually done. +- Perform a careful review and ask for any needed changes. +- Make sure any PRs only ever improve code coverage percentage. +- All PRs should be be reviewed by one individual (not the submitter) and merged by another. + +PRs that are incorrectly merged may (reluctantly) be reverted by the Project Leads. + + +Publishing a Release +-------------------- + +Only Project Leads can publish a release to pypi.org and rtfd.io. This checklist is a reminder +of steps. + +- When planning a new release, create a `milestone + `_ + and assign issues, PRs, etc. to that milestone. +- Review all commits since the last release and confirm that they are properly + documented in the CHANGELOG. (Unfortunately, this has not always been the case + so you may be stuck documenting things that should have been documented as part of their PRs.) +- Make a final PR for the release that updates: + + - CHANGELOG to show the release date. + - setup.cfg to set `version = ...` + +- Once the final PR is committed push the new release to pypi and rtfd.io. diff --git a/docs/getting_started.rst b/docs/getting_started.rst new file mode 100644 index 0000000..427195a --- /dev/null +++ b/docs/getting_started.rst @@ -0,0 +1,394 @@ +Getting started +=============== + +Build a OAuth2 provider using Django, Django OAuth Toolkit, and OAuthLib. + +What we will build? +------------------- + +The plan is to build an OAuth2 provider from ground up. + +On this getting started we will: + +* Create the Django project. +* Install and configure Django OAuth Toolkit. +* Create two OAuth2 applications. +* Use Authorization code grant flow. +* Use Client Credential grant flow. + +What is OAuth? +---------------- + +OAuth is an open standard for access delegation, commonly used as a way for Internet users to grant websites or applications access to their information on other websites but without giving them the passwords. +-- `Whitson Gordon`_ + +Django +------ + +Django is a high-level Python Web framework that encourages rapid development and clean, pragmatic design. Built by experienced developers, it takes care of much of the hassle of Web development, so you can focus on writing your app without needing to reinvent the wheel. +-- `Django website`_ + +Let's get start by creating a virtual environment:: + + mkproject iam + +This will create, activate and change directory to the new Python virtual environment. + +Install Django:: + + pip install Django + +Create a Django project:: + + django-admin startproject iam + +This will create a mysite directory in your current directory. With the following estructure:: + + . + └── iam + ├── iam + │   ├── asgi.py + │   ├── __init__.py + │   ├── settings.py + │   ├── urls.py + │   └── wsgi.py + └── manage.py + +Create a Django application:: + + cd iam/ + python manage.py startapp users + +That’ll create a directory :file:`users`, which is laid out like this:: + + . + ├── iam + │   ├── asgi.py + │   ├── __init__.py + │   ├── settings.py + │   ├── urls.py + │   └── wsgi.py + ├── manage.py + └── users + ├── admin.py + ├── apps.py + ├── __init__.py + ├── migrations + │   └── __init__.py + ├── models.py + ├── tests.py + └── views.py + +If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default `User`_ model is sufficient for you. This model behaves identically to the default user model, but you’ll be able to customize it in the future if the need arises. +-- `Django documentation`_ + +Edit :file:`users/models.py` adding the code below: + +.. code-block:: python + + from django.contrib.auth.models import AbstractUser + + class User(AbstractUser): + pass + +Change :file:`iam/settings.py` to add ``users`` application to ``INSTALLED_APPS``: + +.. code-block:: python + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + ] + +Configure ``users.User`` to be the model used for the ``auth`` application by adding ``AUTH_USER_MODEL`` to :file:`iam/settings.py`: + +.. code-block:: python + + AUTH_USER_MODEL='users.User' + +Create inital migration for ``users`` application ``User`` model:: + + python manage.py makemigrations + +The command above will create the migration:: + + Migrations for 'users': + users/migrations/0001_initial.py + - Create model User + +Finally execute the migration:: + + python manage.py migrate + +The ``migrate`` output:: + + Operations to perform: + Apply all migrations: admin, auth, contenttypes, sessions, users + Running migrations: + Applying contenttypes.0001_initial... OK + Applying contenttypes.0002_remove_content_type_name... OK + Applying auth.0001_initial... OK + Applying auth.0002_alter_permission_name_max_length... OK + Applying auth.0003_alter_user_email_max_length... OK + Applying auth.0004_alter_user_username_opts... OK + Applying auth.0005_alter_user_last_login_null... OK + Applying auth.0006_require_contenttypes_0002... OK + Applying auth.0007_alter_validators_add_error_messages... OK + Applying auth.0008_alter_user_username_max_length... OK + Applying auth.0009_alter_user_last_name_max_length... OK + Applying auth.0010_alter_group_name_max_length... OK + Applying auth.0011_update_proxy_permissions... OK + Applying users.0001_initial... OK + Applying admin.0001_initial... OK + Applying admin.0002_logentry_remove_auto_add... OK + Applying admin.0003_logentry_add_action_flag_choices... OK + Applying sessions.0001_initial... OK + +Django OAuth Toolkit +-------------------- + +Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 capabilities to your Django projects. + +Install Django OAuth Toolkit:: + + pip install django-oauth-toolkit + +Add ``oauth2_provider`` to ``INSTALLED_APPS`` in :file:`iam/settings.py`: + +.. code-block:: python + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'oauth2_provider', + ] + +Execute the migration:: + + python manage.py migrate + +The ``migrate`` command output:: + + Operations to perform: + Apply all migrations: admin, auth, contenttypes, oauth2_provider, sessions, users + Running migrations: + Applying oauth2_provider.0001_initial... OK + Applying oauth2_provider.0002_auto_20190406_1805... OK + +Include ``oauth2_provider.urls`` to :file:`iam/urls.py` as follows: + +.. code-block:: python + + from django.contrib import admin + from django.urls import include, path + + urlpatterns = [ + path('admin/', admin.site.urls), + path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + ] + +This will make available endpoints to authorize, generate token and create OAuth applications. + +Last change, add ``LOGIN_URL`` to :file:`iam/settings.py`: + +.. code-block:: python + + LOGIN_URL='/admin/login/' + +We will use Django Admin login to make our life easy. + +Create a user:: + + python manage.py createsuperuser + + Username: wiliam + Email address: me@wiliam.dev + Password: + Password (again): + Superuser created successfully. + +OAuth2 Authorization Grants +--------------------------- + +An authorization grant is a credential representing the resource owner's authorization (to access its protected resources) used by the client to obtain an access token. +-- `RFC6749`_ + +The OAuth framework specifies several grant types for different use cases. +-- `Grant types`_ + +We will start by given a try to the grant types listed below: + +* Authorization code +* Client credential + +These two grant types cover the most initially used use cases. + +Authorization Code +------------------ + +The Authorization Code flow is best used in web and mobile apps. This is the flow used for third party integration, the user authorizes your partner to access its products in your APIs. + +Start the development server:: + + python manage.py runserver + +Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. + +Fill the form as show in the screenshot bellow and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. + +.. image:: _images/application-register-auth-code.png + :alt: Authorization code application registration + +Export ``Client id`` and ``Client secret`` values as environment variable: + +.. sourcecode:: sh + + export ID=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8 + export SECRET=DZFpuNjRdt5xUEzxXovAp40bU3lQvoMvF3awEStn61RXWE0Ses4RgzHWKJKTvUCHfRkhcBi3ebsEfSjfEO96vo2Sh6pZlxJ6f7KcUbhvqMMPoVxRwv4vfdWEoWMGPeIO + +To start the Authorization code flow go to this `URL`_ which is the same as shown below:: + + http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback + +Note the parameters we pass: + +* **response_type**: ``code`` +* **client_id**: ``vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8`` +* **redirect_uri**: ``http://127.0.0.1:8000/noexist/callback`` + +This identifies your application, the user is asked to authorize your application to access its resources. + +Go ahead and authorize the ``web-app`` + +.. image:: _images/application-authorize-web-app.png + :alt: Authorization code authorize web-app + +Remember we used ``http://127.0.0.1:8000/noexist/callback`` as ``redirect_uri`` you will get a **Page not found (404)** but it worked if you get a url like:: + + http://127.0.0.1:8000/noexist/callback?code=uVqLxiHDKIirldDZQfSnDsmYW1Abj2 + +This is the OAuth2 provider trying to give you a ``code``. in this case ``uVqLxiHDKIirldDZQfSnDsmYW1Abj2``. + +Export it as an environment variable: + +.. code-block:: sh + + export CODE=uVqLxiHDKIirldDZQfSnDsmYW1Abj2 + +Now that you have the user authorization is time to get an access token:: + + curl -X POST -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "client_id=${ID}" -d "client_secret=${SECRET}" -d "code=${CODE}" -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" -d "grant_type=authorization_code" + +To be more easy to visualize:: + + curl -X POST \ + -H "Cache-Control: no-cache" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "http://127.0.0.1:8000/o/token/" \ + -d "client_id=${ID}" \ + -d "client_secret=${SECRET}" \ + -d "code=${CODE}" \ + -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" \ + -d "grant_type=authorization_code" + +The OAuth2 provider will return the follow response: + +.. code-block:: javascript + + { + "access_token": "jooqrnOrNa0BrNWlg68u9sl6SkdFZg", + "expires_in": 36000, + "token_type": "Bearer", + "scope": "read write", + "refresh_token": "HNvDQjjsnvDySaK0miwG4lttJEl9yD" + } + +To access the user resources we just use the ``access_token``:: + + curl \ + -H "Authorization: Bearer jooqrnOrNa0BrNWlg68u9sl6SkdFZg" \ + -X GET http://localhost:8000/resource + +Client Credential +----------------- + +The Client Credential grant is suitable for machine-to-machine authentication. You authorize your own service or worker to change a bank account transaction status to accepted. + +Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. + +Fill the form as show in the screenshot below, and before saving take note of ``Client id`` and ``Client secret`` we will use it in a minute. + +.. image:: _images/application-register-client-credential.png + :alt: Client credential application registration + +Export ``Client id`` and ``Client secret`` values as environment variable: + +.. code-block:: sh + + export ID=axXSSBVuvOyGVzh4PurvKaq5MHXMm7FtrHgDMi4u + export SECRET=1fuv5WVfR7A5BlF0o155H7s5bLgXlwWLhi3Y7pdJ9aJuCdl0XV5Cxgd0tri7nSzC80qyrovh8qFXFHgFAAc0ldPNn5ZYLanxSm1SI1rxlRrWUP591wpHDGa3pSpB6dCZ + +The Client Credential flow is simpler than the Authorization Code flow. + +We need to encode ``client_id`` and ``client_secret`` as HTTP base authentication encoded in ``base64`` I use the following code to do that. + +.. code-block:: python + + >>> import base64 + >>> client_id = "axXSSBVuvOyGVzh4PurvKaq5MHXMm7FtrHgDMi4u" + >>> secret = "1fuv5WVfR7A5BlF0o155H7s5bLgXlwWLhi3Y7pdJ9aJuCdl0XV5Cxgd0tri7nSzC80qyrovh8qFXFHgFAAc0ldPNn5ZYLanxSm1SI1rxlRrWUP591wpHDGa3pSpB6dCZ" + >>> credential = "{0}:{1}".format(client_id, secret) + >>> base64.b64encode(credential.encode("utf-8")) + b'YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg==' + >>> + +Export the credential as an environment variable + +.. code-block:: sh + + export CREDENTIAL=YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg== + +To start the Client Credential flow you call ``/token/`` endpoint direct:: + + curl -X POST -H "Authorization: Basic ${CREDENTIAL}" -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "grant_type=client_credentials" + +To be easier to visualize:: + + curl -X POST \ + -H "Authorization: Basic ${CREDENTIAL}" \ + -H "Cache-Control: no-cache" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "http://127.0.0.1:8000/o/token/" \ + -d "grant_type=client_credentials" + +The OAuth2 provider will return the following response: + +.. code-block:: javascript + + { + "access_token": "PaZDOD5UwzbGOFsQr34LQ7JUYOj3yK", + "expires_in": 36000, + "token_type": "Bearer", + "scope": "read write" + } + +Next step is :doc:`first tutorial `. + +.. _Django website: https://www.djangoproject.com/ +.. _Whitson Gordon: https://en.wikipedia.org/wiki/OAuth#cite_note-1 +.. _User: https://docs.djangoproject.com/en/3.0/ref/contrib/auth/#django.contrib.auth.models.User +.. _Django documentation: https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#using-a-custom-user-model-when-starting-a-project +.. _RFC6749: https://tools.ietf.org/html/rfc6749#section-1.3 +.. _Grant Types: https://oauth.net/2/grant-types/ +.. _URL: http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback + diff --git a/docs/index.rst b/docs/index.rst index 8716eb9..d2d4e8c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Welcome to Django OAuth Toolkit Documentation ============================================= -Django OAuth Toolkit can help you providing out of the box all the endpoints, data and logic needed to add OAuth2 +Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent `OAuthLib `_, so that everything is `rfc-compliant `_. @@ -16,14 +16,14 @@ See our :doc:`Changelog ` for information on updates. Support ------- -If you need support please send a message to the `Django OAuth Toolkit Google Group `_ +If you need help please submit a `question `_. Requirements ------------ -* Python 3.5+ -* Django 2.1+ -* oauthlib 3.0+ +* Python 3.6+ +* Django 2.2+ +* oauthlib 3.1+ Index ===== @@ -32,6 +32,7 @@ Index :maxdepth: 2 install + getting_started tutorial/tutorial rest-framework/rest-framework views/views @@ -39,6 +40,7 @@ Index views/details models advanced_topics + oidc signals settings resource_server diff --git a/docs/install.rst b/docs/install.rst index ccff177..65dcb1d 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -19,11 +19,22 @@ If you need an OAuth2 provider you'll want to add the following to your urls.py .. code-block:: python + from django.urls import include, path + urlpatterns = [ ... path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + ] + +Or using `re_path()` + +.. code-block:: python + + from django.urls import include, re_path + + urlpatterns = [ + ... - # using re_path re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] @@ -34,4 +45,5 @@ Sync your database $ python manage.py migrate oauth2_provider -Next step is our :doc:`first tutorial `. +Next step is :doc:`getting started ` or :doc:`first tutorial `. + diff --git a/docs/models.rst b/docs/models.rst index 8fcbdc5..1e2657c 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -1,5 +1,5 @@ -`Models` -======== +Models +====== .. automodule:: oauth2_provider.models :members: diff --git a/docs/oidc.rst b/docs/oidc.rst new file mode 100644 index 0000000..87fadce --- /dev/null +++ b/docs/oidc.rst @@ -0,0 +1,308 @@ +OpenID Connect +++++++++++++++ + +OpenID Connect support +====================== + +``django-oauth-toolkit`` supports OpenID Connect (OIDC), which standardizes +authentication flows and provides a plug and play integration with other +systems. OIDC is built on top of OAuth 2.0 to provide: + +* Generating ID tokens as part of the login process. These are JWT that + describe the user, and can be used to authenticate them to your application. +* Metadata based auto-configuration for providers +* A user info endpoint, which applications can query to get more information + about a user. + +Enabling OIDC doesn't affect your existing OAuth 2.0 flows, these will +continue to work alongside OIDC. + +We support: + +* OpenID Connect Authorization Code Flow +* OpenID Connect Implicit Flow +* OpenID Connect Hybrid Flow + + +Configuration +============= + +OIDC is not enabled by default because it requires additional configuration +that must be provided. ``django-oauth-toolkit`` supports two different +algorithms for signing JWT tokens, ``RS256``, which uses asymmetric RSA keys (a +public key and a private key), and ``HS256``, which uses a symmetric key. + +It is preferrable to use ``RS256``, because this produces a token that can be +verified by anyone using the public key (which is made available and +discoverable by OIDC service auto-discovery, included with +``django-oauth-toolkit``). ``HS256`` on the other hand uses the +``client_secret`` in order to verify keys. This is simpler to implement, but +makes it harder to safely verify tokens. + +Using ``HS256`` also means that you cannot use the Implicit or Hybrid flows, +or verify the tokens in public clients, because you cannot disclose the +``client_secret`` to a public client. If you are using a public client, you +must use ``RS256``. + + +Creating RSA private key +~~~~~~~~~~~~~~~~~~~~~~~~ + +To use ``RS256`` requires an RSA private key, which is used for signing JWT. You +can generate this using the `openssl`_ tool:: + + openssl genrsa -out oidc.key 4096 + +This will generate a 4096-bit RSA key, which will be sufficient for our needs. + +.. _openssl: https://www.openssl.org + +.. warning:: + The contents of this key *must* be kept a secret. Don't put it in your + settings and commit it to version control! + + If the key is ever accidentally disclosed, an attacker could use it to + forge JWT tokens that verify as issued by your OAuth provider, which is + very bad! + + If it is ever disclosed, you should immediately replace the key. + + Safe ways to handle it would be: + + * Store it in a secure system like `Hashicorp Vault`_, and inject it in to + your environment when running your server. + * Store it in a secure file on your server, and use your initialization + scripts to inject it in to your environment. + +.. _Hashicorp Vault: https://www.hashicorp.com/products/vault + +Now we need to add this key to our settings and allow the ``openid`` scope to +be used. Assuming we have set an environment variable called +``OIDC_RSA_PRIVATE_KEY``, we can make changes to our ``settings.py``:: + + import os.environ + + OAUTH2_PROVIDER = { + "OIDC_ENABLED": True, + "OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"), + "SCOPES": { + "openid": "OpenID Connect scope", + # ... any other scopes that you use + }, + # ... any other settings you want + } + +If you are adding OIDC support to an existing OAuth 2.0 provider site, and you +are currently using a custom class for ``OAUTH2_SERVER_CLASS``, you must +change this class to derive from ``oauthlib.openid.Server`` instead of +``oauthlib.oauth2.Server``. + +With ``RSA`` key-pairs, the public key can be generated from the private key, +so there is no need to add a setting for the public key. + +Using ``HS256`` keys +~~~~~~~~~~~~~~~~~~~~ + +If you would prefer to use just ``HS256`` keys, you don't need to create any +additional keys, ``django-oauth-toolkit`` will just use the application's +``client_secret`` to sign the JWT token. + +In this case, you just need to enable OIDC and add ``openid`` to your list of +scopes in your ``settings.py``:: + + OAUTH2_PROVIDER = { + "OIDC_ENABLED": True, + "SCOPES": { + "openid": "OpenID Connect scope", + # ... any other scopes that you use + }, + # ... any other settings you want + } + +.. info:: + If you want to enable ``RS256`` at a later date, you can do so - just add + the private key as described above. + +Setting up OIDC enabled clients +=============================== + +Setting up an OIDC client in ``django-oauth-toolkit`` is simple - in fact, all +existing OAuth 2.0 Authorization Code Flow and Implicit Flow applications that +are already configured can be easily updated to use OIDC by setting the +appropriate algorithm for them to use. + +You can also switch existing apps to use OIDC Hybrid Flow by changing their +Authorization Grant Type and selecting a signing algorithm to use. + +You can read about the pros and cons of the different flows in `this excellent +article`_ from Robert Broeckelmann. + +.. _this excellent article: https://medium.com/@robert.broeckelmann/when-to-use-which-oauth2-grants-and-oidc-flows-ec6a5c00d864 + +OIDC Authorization Code Flow +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To create an OIDC Authorization Code Flow client, create an ``Application`` +with the grant type ``Authorization code`` and select your desired signing +algorithm. + +When making an authorization request, be sure to include ``openid`` as a +scope. When the code is exchanged for the access token, the response will +also contain an ID token JWT. + +If the ``openid`` scope is not requested, authorization requests will be +treated as standard OAuth 2.0 Authorization Code Grant requests. + +With ``PKCE`` enabled, even public clients can use this flow, and it is the most +secure and recommended flow. + +OIDC Implicit Flow +~~~~~~~~~~~~~~~~~~ + +OIDC Implicit Flow is very similar to OAuth 2.0 Implicit Grant, except that +the client can request a ``response_type`` of ``id_token`` or ``id_token +token``. Requesting just ``token`` is also possible, but it would make it not +an OIDC flow and would fall back to being the same as OAuth 2.0 Implicit +Grant. + +To setup an OIDC Implicit Flow client, simply create an ``Application`` with +the a grant type of ``Implicit`` and select your desired signing algorithm, +and configure the client to request the ``openid`` scope and an OIDC +``response_type`` (``id_token`` or ``id_token token``). + + +OIDC Hybrid Flow +~~~~~~~~~~~~~~~~ + +OIDC Hybrid Flow is a mixture of the previous two flows. It allows the ID +token and an access token to be returned to the frontend, whilst also +allowing the backend to retrieve the ID token and an access token (not +necessarily the same access token) on the backend. + +To setup an OIDC Hybrid Flow application, create an ``Application`` with a +grant type of ``OpenID connect hybrid`` and select your desired signing +algorithm. + + +Customizing the OIDC responses +============================== + +This basic configuration will give you a basic working OIDC setup, but your +ID tokens will have very few claims in them, and the ``UserInfo`` service will +just return the same claims as the ID token. + +To configure all of these things we need to customize the +``OAUTH2_VALIDATOR_CLASS`` in ``django-oauth-toolkit``. Create a new file in +our project, eg ``my_project/oauth_validator.py``:: + + from oauth2_provider.oauth2_validators import OAuth2Validator + + + class CustomOAuth2Validator(OAuth2Validator): + pass + + +and then configure our site to use this in our ``settings.py``:: + + OAUTH2_PROVIDER = { + "OAUTH2_VALIDATOR_CLASS": "my_project.oauth_validators.CustomOAuth2Validator", + # ... other settings + } + +Now we can customize the tokens and the responses that are produced by adding +methods to our custom validator. + + +Adding claims to the ID token +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default the ID token will just have a ``sub`` claim (in addition to the +required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), +and the ``sub`` claim will use the primary key of the user as the value. +You'll probably want to customize this and add additional claims or change +what is sent for the ``sub`` claim. To do so, you will need to add a method to +our custom validator:: + + class CustomOAuth2Validator(OAuth2Validator): + + def get_additional_claims(self, request): + return { + "sub": request.user.email, + "first_name": request.user.first_name, + "last_name": request.user.last_name, + } + +.. note:: + This ``request`` object is not a ``django.http.Request`` object, but an + ``oauthlib.common.Request`` object. This has a number of attributes that + you can use to decide what claims to put in to the ID token: + + * ``request.scopes`` - a list of the scopes requested by the client when + making an authorization request. + * ``request.claims`` - a dictionary of the requested claims, using the + `OIDC claims requesting system`_. These must be requested by the client + when making an authorization request. + * ``request.user`` - the django user object. + +.. _OIDC claims requesting system: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter + +What claims you decide to put in to the token is up to you to determine based +upon what the scopes and / or claims means to your provider. + + +Adding information to the ``UserInfo`` service +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``UserInfo`` service is supplied as part of the OIDC service, and is used +to retrieve more information about the user than was supplied in the ID token +when the user logged in to the OIDC client application. It is optional to use +the service. The service is accessed by making a request to the +``UserInfo`` endpoint, eg ``/o/userinfo/`` and supplying the access token +retrieved at login as a ``Bearer`` token. + +Again, to modify the content delivered, we need to add a function to our +custom validator. The default implementation adds the claims from the ID +token, so you will probably want to re-use that:: + + class CustomOAuth2Validator(OAuth2Validator): + + def get_userinfo_claims(self, request): + claims = super().get_userinfo_claims(request) + claims["color_scheme"] = get_color_scheme(request.user) + return claims + + +OIDC Views +========== + +Enabling OIDC support adds three views to ``django-oauth-toolkit``. When OIDC +is not enabled, these views will log that OIDC support is not enabled, and +return a ``404`` response, or if ``DEBUG`` is enabled, raise an +``ImproperlyConfigured`` exception. + +In the docs below, it assumes that you have mounted the +``django-oauth-toolkit`` at ``/o/``. If you have mounted it elsewhere, adjust +the URLs accordingly. + + +ConnectDiscoveryInfoView +~~~~~~~~~~~~~~~~~~~~~~~~ + +Available at ``/o/.well-known/openid-configuration/``, this view provides auto +discovery information to OIDC clients, telling them the JWT issuer to use, the +location of the JWKs to verify JWTs with, the token and userinfo endpoints to +query, and other details. + + +JwksInfoView +~~~~~~~~~~~~ + +Available at ``/o/.well-known/jwks.json``, this view provides details of the key used to sign +the JWTs generated for ID tokens, so that clients are able to verify them. + + +UserInfoView +~~~~~~~~~~~~ + +Available at ``/o/userinfo/``, this view provides extra user details. You can +customize the details included in the response as described above. diff --git a/docs/requirements.txt b/docs/requirements.txt index 63d8276..c1f7269 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ Django>=3.0,<3.1 -oauthlib>=3.0.1 +oauthlib>=3.1.0 m2r>=0.2.1 . diff --git a/docs/settings.rst b/docs/settings.rst index d0bc62e..de7bcf8 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -31,7 +31,7 @@ ACCESS_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds an access token remains valid. Requesting a protected resource after this duration will fail. Keep this value high enough so clients -can cache the token for a reasonable amount of time. +can cache the token for a reasonable amount of time. (default: 36000) ACCESS_TOKEN_MODEL ~~~~~~~~~~~~~~~~~~ @@ -52,6 +52,11 @@ Default: ``["http", "https"]`` A list of schemes that the ``redirect_uri`` field will be validated against. Setting this to ``["https"]`` only in production is strongly recommended. +For Native Apps the ``http`` scheme can be safely used with loopback addresses in the +Application (``[::1]`` or ``127.0.0.1``). In this case the ``redirect_uri`` can be +configured without explicit port specification, so that the Application accepts randomly +assigned ports. + Note that you may override ``Application.get_allowed_schemes()`` to set this on a per-application basis. @@ -97,10 +102,36 @@ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). +APPLICATION_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your application admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.ApplicationAdmin``). + +ACCESS_TOKEN_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your access token admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.AccessTokenAdmin``). + +GRANT_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your grant admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.GrantAdmin``). + +REFRESH_TOKEN_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your refresh token admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.RefreshTokenAdmin``). + OAUTH2_SERVER_CLASS ~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) -used in the ``OAuthLibMixin`` that implements OAuth2 grant types. +used in the ``OAuthLibMixin`` that implements OAuth2 grant types. It defaults +to ``oauthlib.oauth2.Server``, except when OIDC support is enabled, when the +default is ``oauthlib.openid.Server``. OAUTH2_VALIDATOR_CLASS ~~~~~~~~~~~~~~~~~~~~~~ @@ -118,7 +149,7 @@ The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. NOTE: This value is completely ignored when validating refresh tokens. If you don't change the validator code and don't run cleartokens all refresh -tokens will last until revoked or the end of time. +tokens will last until revoked or the end of time. You should change this. REFRESH_TOKEN_GRACE_PERIOD_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -198,12 +229,18 @@ Only applicable when used with `Django REST Framework /o/userinfo/``. + +OIDC_ISS_ENDPOINT +~~~~~~~~~~~~~~~~~ +Default: ``""`` + +The URL of the issuer that is used in the ID token JWT and advertised in the +OIDC discovery metadata. Clients use this location to retrieve the OIDC +discovery metadata from ``OIDC_ISS_ENDPOINT`` + +``/.well-known/openid-configuration/``. + +If unset, the default location is used, eg if ``django-oauth-toolkit`` is +mounted at ``/o``, it will be ``/o``. + +OIDC_RESPONSE_TYPES_SUPPORTED +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default:: + + [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ] + + +The response types that are advertised to be supported by this server. + +OIDC_SUBJECT_TYPES_SUPPORTED +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``["public"]`` + +The subject types that are advertised to be supported by this server. + +OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``["client_secret_post", "client_secret_basic"]`` + +The authentication methods that are advertised to be supported by this server. + + +Settings imported from Django project +-------------------------- + +USE_TZ +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Used to determine whether or not to make token expire dates timezone aware. diff --git a/docs/templates.rst b/docs/templates.rst index 4b7e103..4f6320b 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -100,7 +100,7 @@ Example (this is the default page you may find on ``templates/oauth2_provider/au {% endif %} {% endfor %} -

{% trans "Application requires following permissions" %}

+

{% trans "Application requires the following permissions" %}

- New Application + {% trans "New Application" %} {% else %} +

{% trans "No applications defined" %}. {% trans "Click here" %} {% trans "if you want to register a new one" %}

{% endif %} diff --git a/oauth2_provider/templates/oauth2_provider/authorize.html b/oauth2_provider/templates/oauth2_provider/authorize.html index 6e6a2a9..dcbcda7 100644 --- a/oauth2_provider/templates/oauth2_provider/authorize.html +++ b/oauth2_provider/templates/oauth2_provider/authorize.html @@ -14,7 +14,7 @@

{% trans "Authorize" %} {{ application.name }}? {% endif %} {% endfor %} -

{% trans "Application requires following permissions" %}

+

{% trans "Application requires the following permissions" %}

    {% for scope in scopes_descriptions %}
  • {{ scope }}
  • @@ -26,8 +26,8 @@

    {% trans "Authorize" %} {{ application.name }}?
    - - + +
    diff --git a/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html index e08233a..02a6ff4 100644 --- a/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html +++ b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html @@ -4,6 +4,6 @@ {% block content %}
    {% csrf_token %}

    {% trans "Are you sure you want to delete this token?" %}

    - +
    {% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html index 2c6a028..0f27325 100644 --- a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html +++ b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html @@ -8,7 +8,7 @@

    {% trans "Tokens" %}

    {% for authorized_token in authorized_tokens %}
  • {{ authorized_token.application }} - (revoke) + ({% trans "revoke" %})
    • {% for scope_name, scope_description in authorized_token.scopes.items %} diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 4baef47..508f97c 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import re_path from . import views @@ -7,31 +7,37 @@ base_urlpatterns = [ - url(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), - url(r"^token/$", views.TokenView.as_view(), name="token"), - url(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), - url(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), + re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), + re_path(r"^token/$", views.TokenView.as_view(), name="token"), + re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), + re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), ] management_urlpatterns = [ # Application management views - url(r"^applications/$", views.ApplicationList.as_view(), name="list"), - url(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), - url(r"^applications/(?P[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), - url(r"^applications/(?P[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), - url(r"^applications/(?P[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), + re_path(r"^applications/$", views.ApplicationList.as_view(), name="list"), + re_path(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), + re_path(r"^applications/(?P[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), + re_path(r"^applications/(?P[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), + re_path(r"^applications/(?P[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views - url(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - url(r"^authorized_tokens/(?P[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), - name="authorized-token-delete"), + re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + re_path( + r"^authorized_tokens/(?P[\w-]+)/delete/$", + views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete", + ), ] oidc_urlpatterns = [ - url(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), - name="oidc-connect-discovery-info"), - url(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), - url(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") + re_path( + r"^\.well-known/openid-configuration/$", + views.ConnectDiscoveryInfoView.as_view(), + name="oidc-connect-discovery-info", + ), + re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"), + re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"), ] diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 4a4fabf..6c8fa38 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -10,12 +10,9 @@ class URIValidator(URLValidator): scheme_re = r"^(?:[a-z][a-z0-9\.\-\+]*)://" dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(?= 2.2.0 + django >= 2.2 requests >= 2.13.0 - oauthlib >= 3.0.1 - jwcrypto >= 0.4.2 + oauthlib >= 3.1.0 + jwcrypto >= 0.8.0 + six [options.packages.find] exclude = tests diff --git a/tests/admin.py b/tests/admin.py new file mode 100644 index 0000000..f071769 --- /dev/null +++ b/tests/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + + +class CustomApplicationAdmin(admin.ModelAdmin): + list_display = ("id",) + + +class CustomAccessTokenAdmin(admin.ModelAdmin): + list_display = ("id",) + + +class CustomGrantAdmin(admin.ModelAdmin): + list_display = ("id",) + + +class CustomIDTokenAdmin(admin.ModelAdmin): + list_display = ("id",) + + +class CustomRefreshTokenAdmin(admin.ModelAdmin): + list_display = ("id",) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a3274aa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,156 @@ +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +import pytest +from django.conf import settings as test_settings +from django.contrib.auth import get_user_model +from django.urls import reverse +from jwcrypto import jwk + +from oauth2_provider.models import get_application_model +from oauth2_provider.settings import oauth2_settings as _oauth2_settings + +from . import presets + + +Application = get_application_model() +UserModel = get_user_model() + + +class OAuthSettingsWrapper: + """ + A wrapper around oauth2_settings to ensure that when an overridden value is + set, it also records it in _cached_attrs, so that the settings can be reset. + """ + + def __init__(self, settings, user_settings): + self.settings = settings + if not user_settings: + user_settings = {} + self.update(user_settings) + + def update(self, user_settings): + self.settings.OAUTH2_PROVIDER = user_settings + _oauth2_settings.reload() + # Reload OAuthlibCore for every view request during tests + self.ALWAYS_RELOAD_OAUTHLIB_CORE = True + + def __setattr__(self, attr, value): + if attr == "settings": + super().__setattr__(attr, value) + else: + setattr(_oauth2_settings, attr, value) + _oauth2_settings._cached_attrs.add(attr) + + def __delattr__(self, attr): + delattr(_oauth2_settings, attr) + if attr in _oauth2_settings._cached_attrs: + _oauth2_settings._cached_attrs.remove(attr) + + def __getattr__(self, attr): + return getattr(_oauth2_settings, attr) + + def finalize(self): + self.settings.finalize() + _oauth2_settings.reload() + + +@pytest.fixture +def oauth2_settings(request, settings): + """ + A fixture that provides a simple way to override OAUTH2_PROVIDER settings. + + It can be used two ways - either setting things on the fly, or by reading + configuration data from the pytest marker oauth2_settings. + + If used on a standard pytest function, you can use argument dependency + injection to get the wrapper. If used on a unittest.TestCase, the wrapper + is made available on the class instance, as `oauth2_settings`. + + Anything overridden will be restored at the end of the test case, ensuring + that there is no configuration leakage between test cases. + """ + marker = request.node.get_closest_marker("oauth2_settings") + user_settings = {} + if marker is not None: + user_settings = marker.args[0] + wrapper = OAuthSettingsWrapper(settings, user_settings) + if request.instance is not None: + request.instance.oauth2_settings = wrapper + yield wrapper + wrapper.finalize() + + +@pytest.fixture(scope="session") +def oidc_key_(): + return jwk.JWK.from_pem(test_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + + +@pytest.fixture +def oidc_key(request, oidc_key_): + if request.instance is not None: + request.instance.key = oidc_key_ + return oidc_key_ + + +@pytest.fixture +def application(): + return Application.objects.create( + name="Test Application", + redirect_uris="http://example.org", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + algorithm=Application.RS256_ALGORITHM, + ) + + +@pytest.fixture +def hybrid_application(application): + application.authorization_grant_type = application.GRANT_OPENID_HYBRID + application.save() + return application + + +@pytest.fixture +def test_user(): + return UserModel.objects.create_user("test_user", "test@example.com", "123456") + + +@pytest.fixture +def oidc_tokens(oauth2_settings, application, test_user, client): + oauth2_settings.update(presets.OIDC_SETTINGS_RW) + client.force_login(test_user) + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={ + "client_id": application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + }, + ) + assert auth_rsp.status_code == 302 + code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"] + client.logout() + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": application.client_id, + "client_secret": application.client_secret, + "scope": "openid", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + return SimpleNamespace( + user=test_user, + application=application, + access_token=token_data["access_token"], + id_token=token_data["id_token"], + oauth2_settings=oauth2_settings, + ) diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index eef6dba..8903a5a 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -33,6 +33,8 @@ class Migration(migrations.Migration): ('custom_field', models.CharField(max_length=255)), ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplegrant', to=settings.AUTH_USER_MODEL)), + ("nonce", models.CharField(blank=True, max_length=255, default="")), + ("claims", models.TextField(blank=True)), ], options={ 'abstract': False, diff --git a/tests/models.py b/tests/models.py index 7ca0c57..32f9a1b 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,8 +1,10 @@ from django.db import models from oauth2_provider.models import ( - AbstractAccessToken, AbstractApplication, - AbstractGrant, AbstractRefreshToken + AbstractAccessToken, + AbstractApplication, + AbstractGrant, + AbstractRefreshToken, ) from oauth2_provider.settings import oauth2_settings @@ -13,7 +15,7 @@ class BaseTestApplication(AbstractApplication): def get_allowed_schemes(self): if self.allowed_schemes: return self.allowed_schemes.split() - return super(BaseTestApplication, self).get_allowed_schemes() + return super().get_allowed_schemes() class SampleApplication(AbstractApplication): @@ -24,16 +26,22 @@ class SampleAccessToken(AbstractAccessToken): custom_field = models.CharField(max_length=255) source_refresh_token = models.OneToOneField( # unique=True implied by the OneToOneField - oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="s_refreshed_access_token" + oauth2_settings.REFRESH_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="s_refreshed_access_token", ) class SampleRefreshToken(AbstractRefreshToken): custom_field = models.CharField(max_length=255) access_token = models.OneToOneField( - oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="s_refresh_token" + oauth2_settings.ACCESS_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="s_refresh_token", ) diff --git a/tests/presets.py b/tests/presets.py new file mode 100644 index 0000000..214f804 --- /dev/null +++ b/tests/presets.py @@ -0,0 +1,45 @@ +from copy import deepcopy + +from django.conf import settings + + +# A set of OAUTH2_PROVIDER settings dicts that can be used in tests + +DEFAULT_SCOPES_RW = {"DEFAULT_SCOPES": ["read", "write"]} +DEFAULT_SCOPES_RO = {"DEFAULT_SCOPES": ["read"]} +OIDC_SETTINGS_RW = { + "OIDC_ENABLED": True, + "OIDC_ISS_ENDPOINT": "http://localhost/o", + "OIDC_USERINFO_ENDPOINT": "http://localhost/o/userinfo/", + "OIDC_RSA_PRIVATE_KEY": settings.OIDC_RSA_PRIVATE_KEY, + "SCOPES": { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect", + }, + "DEFAULT_SCOPES": ["read", "write"], +} +OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) +OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] +OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW) +del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"] +REST_FRAMEWORK_SCOPES = { + "SCOPES": { + "read": "Read scope", + "write": "Write scope", + "scope1": "Scope 1", + "scope2": "Scope 2", + "resource1": "Resource 1", + }, +} +INTROSPECTION_SETTINGS = { + "SCOPES": { + "read": "Read scope", + "write": "Write scope", + "introspection": "Introspection scope", + "dolphin": "eek eek eek scope", + }, + "RESOURCE_SERVER_INTROSPECTION_URL": "http://example.org/introspection", + "READ_SCOPE": "read", + "WRITE_SCOPE": "write", +} diff --git a/tests/settings.py b/tests/settings.py index edd1ae6..1d29598 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -80,7 +80,6 @@ "django.contrib.staticfiles", "django.contrib.admin", "django.contrib.messages", - "oauth2_provider", "tests", ) @@ -89,29 +88,17 @@ "version": 1, "disable_existing_loggers": False, "formatters": { - "verbose": { - "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" - }, - "simple": { - "format": "%(levelname)s %(message)s" - }, - }, - "filters": { - "require_debug_false": { - "()": "django.utils.log.RequireDebugFalse" - } + "verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"}, + "simple": {"format": "%(levelname)s %(message)s"}, }, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, "handlers": { "mail_admins": { "level": "ERROR", "filters": ["require_debug_false"], - "class": "django.utils.log.AdminEmailHandler" - }, - "console": { - "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "simple" + "class": "django.utils.log.AdminEmailHandler", }, + "console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "simple"}, "null": { "level": "DEBUG", "class": "logging.NullHandler", @@ -128,7 +115,7 @@ "level": "DEBUG", "propagate": True, }, - } + }, } OIDC_RSA_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- @@ -147,12 +134,6 @@ dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY -----END RSA PRIVATE KEY-----""" -OAUTH2_PROVIDER = { - "OIDC_ISS_ENDPOINT": "http://localhost", - "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", - "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY, -} - OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 64e112d..42eb17f 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -1,9 +1,9 @@ +import pytest from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from oauth2_provider.models import get_application_model -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views.application import ApplicationRegistration from .models import SampleApplication @@ -23,22 +23,19 @@ def tearDown(self): self.bar_user.delete() +@pytest.mark.usefixtures("oauth2_settings") class TestApplicationRegistrationView(BaseTest): - + @pytest.mark.oauth2_settings({"APPLICATION_MODEL": "tests.SampleApplication"}) def test_get_form_class(self): """ Tests that the form class returned by the "get_form_class" method is bound to custom application model defined in the "OAUTH2_PROVIDER_APPLICATION_MODEL" setting. """ - # Patch oauth2 settings to use a custom Application model - oauth2_settings.APPLICATION_MODEL = "tests.SampleApplication" # Create a registration view and tests that the model form is bound # to the custom Application model application_form_class = ApplicationRegistration().get_form_class() self.assertEqual(SampleApplication, application_form_class._meta.model) - # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" def test_application_registration_user(self): self.client.login(username="foo_user", password="123456") @@ -50,7 +47,7 @@ def test_application_registration_user(self): "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, - "algorithm": "RS256", + "algorithm": "", } response = self.client.post(reverse("oauth2_provider:register"), form_data) @@ -63,15 +60,16 @@ def test_application_registration_user(self): class TestApplicationViews(BaseTest): def _create_application(self, name, user): app = Application.objects.create( - name=name, redirect_uris="http://example.com", + name=name, + redirect_uris="http://example.com", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - user=user + user=user, ) return app def setUp(self): - super(TestApplicationViews, self).setUp() + super().setUp() self.app_foo_1 = self._create_application("app foo_user 1", self.foo_user) self.app_foo_2 = self._create_application("app foo_user 2", self.foo_user) self.app_foo_3 = self._create_application("app foo_user 3", self.foo_user) @@ -80,7 +78,7 @@ def setUp(self): self.app_bar_2 = self._create_application("app bar_user 2", self.bar_user) def tearDown(self): - super(TestApplicationViews, self).tearDown() + super().tearDown() get_application_model().objects.all().delete() def test_application_list(self): diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 530caa7..151fc30 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -19,17 +19,17 @@ class BaseTest(TestCase): """ Base class for cases in this module """ + def setUp(self): self.user = UserModel.objects.create_user("user", "test@example.com", "123456") self.app = ApplicationModel.objects.create( name="app", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_CLIENT_CREDENTIALS, - user=self.user + user=self.user, ) self.token = AccessTokenModel.objects.create( - user=self.user, token="tokstr", application=self.app, - expires=now() + timedelta(days=365) + user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) ) self.factory = RequestFactory() @@ -40,7 +40,6 @@ def tearDown(self): class TestOAuth2Backend(BaseTest): - def test_authenticate(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", @@ -83,58 +82,62 @@ def test_get_user(self): } ) class TestOAuth2Middleware(BaseTest): - def setUp(self): - super(TestOAuth2Middleware, self).setUp() + super().setUp() self.anon_user = AnonymousUser() + def dummy_get_response(self, request): + return HttpResponse() + def test_middleware_wrong_headers(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) request = self.factory.get("/a-resource") - self.assertIsNone(m.process_request(request)) + m(request) + self.assertFalse(hasattr(request, "user")) auth_headers = { "HTTP_AUTHORIZATION": "Beerer " + "badstring", # a Beer token for you! } request = self.factory.get("/a-resource", **auth_headers) - self.assertIsNone(m.process_request(request)) + m(request) + self.assertFalse(hasattr(request, "user")) def test_middleware_user_is_set(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) request.user = self.user - self.assertIsNone(m.process_request(request)) + m(request) + self.assertIs(request.user, self.user) request.user = self.anon_user - self.assertIsNone(m.process_request(request)) + m(request) + self.assertEqual(request.user.pk, self.user.pk) def test_middleware_success(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) - m.process_request(request) + m(request) self.assertEqual(request.user, self.user) def test_middleware_response(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) - response = HttpResponse() - processed = m.process_response(request, response) - self.assertIs(response, processed) + response = m(request) + self.assertIsInstance(response, HttpResponse) def test_middleware_response_header(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) - response = HttpResponse() - m.process_response(request, response) + response = m(request) self.assertIn("Vary", response) self.assertIn("Authorization", response["Vary"]) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 0c6c717..ea1bee8 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -3,22 +3,26 @@ import hashlib import json import re -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import parse_qs, urlparse +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from django.utils.crypto import get_random_string +from jwcrypto import jwt from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView +from . import presets from .utils import get_basic_auth_header @@ -38,17 +42,14 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user( - "test_user", "test@example.com", "123456" - ) - self.dev_user = UserModel.objects.create_user( - "dev_user", "dev@example.com", "123456" - ) + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application.objects.create( name="Test Application", @@ -61,14 +62,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write", "openid"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - oauth2_settings.SCOPES = { - "read": "Reading scope", - "write": "Writing scope", - "openid": "OpenID connect", - } - def tearDown(self): self.application.delete() self.test_user.delete() @@ -83,24 +76,21 @@ class TestRegressionIssue315(BaseTest): def test_request_is_not_overwritten(self): self.client.login(username="test_user", password="123456") - query_string = urlencode( + response = self.client.get( + reverse("oauth2_provider:authorize"), { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - } + }, ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) - - response = self.client.get(url) self.assertEqual(response.status_code, 200) assert "request" not in response.context_data +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestAuthorizationCodeView(BaseTest): def test_skip_authorization_completely(self): """ @@ -110,44 +100,16 @@ def test_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode( + response = self.client.get( + reverse("oauth2_provider:authorize"), { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) - - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - - def test_id_token_skip_authorization_completely(self): - """ - If application.skip_authorization = True, should skip the authorization page. - """ - self.client.login(username="test_user", password="123456") - self.application.skip_authorization = True - self.application.save() - - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - } + }, ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) - - response = self.client.get(url) self.assertEqual(response.status_code, 302) def test_pre_auth_invalid_client(self): @@ -156,14 +118,12 @@ def test_pre_auth_invalid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode( - {"client_id": "fakeclientid", "response_type": "code", } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": "fakeclientid", + "response_type": "code", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) self.assertEqual( response.context_data["url"], @@ -176,20 +136,15 @@ def test_pre_auth_valid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid @@ -201,37 +156,6 @@ def test_pre_auth_valid_client(self): self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) - def test_id_token_pre_auth_valid_client(self): - """ - Test response for a valid client_id with response_type: code - """ - self.client.login(username="test_user", password="123456") - - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - # check form is in context and form params are valid - self.assertIn("form", response.context) - - form = response.context["form"] - self.assertEqual(form["redirect_uri"].value(), "http://example.org") - self.assertEqual(form["state"].value(), "random_state_string") - self.assertEqual(form["scope"].value(), "openid") - self.assertEqual(form["client_id"].value(), self.application.client_id) - def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ Test response for a valid client_id with response_type: code @@ -239,20 +163,15 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "custom-scheme://example.com", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid @@ -273,29 +192,26 @@ def test_pre_auth_approval_prompt(self): scope="read write", ) self.client.login(username="test_user", password="123456") - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "approval_prompt": "auto", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) - response = self.client.get(url) + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "approval_prompt": "auto", + } + url = reverse("oauth2_provider:authorize") + response = self.client.get(url, data=query_data) self.assertEqual(response.status_code, 302) # user already authorized the application, but with different scopes: prompt them. tok.scope = "read" tok.save() - response = self.client.get(url) + response = self.client.get(url, data=query_data) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default(self): - self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") + self.assertEqual(self.oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( user=self.test_user, @@ -305,23 +221,18 @@ def test_pre_auth_approval_prompt_default(self): scope="read write", ) self.client.login(username="test_user", password="123456") - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) - response = self.client.get(url) + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default_override(self): - oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" + self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( user=self.test_user, @@ -331,19 +242,14 @@ def test_pre_auth_approval_prompt_default_override(self): scope="read write", ) self.client.login(username="test_user", password="123456") - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) - response = self.client.get(url) + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) def test_pre_auth_default_redirect(self): @@ -352,14 +258,12 @@ def test_pre_auth_default_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode( - {"client_id": self.application.client_id, "response_type": "code", } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) form = response.context["form"] @@ -371,18 +275,13 @@ def test_pre_auth_forbibben_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "redirect_uri": "http://forbidden.it", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "http://forbidden.it", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_pre_auth_wrong_response_type(self): @@ -391,14 +290,12 @@ def test_pre_auth_wrong_response_type(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode( - {"client_id": self.application.client_id, "response_type": "WRONG", } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "response_type": "WRONG", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=unsupported_response_type", response["Location"]) @@ -417,32 +314,7 @@ def test_code_post_auth_allow(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org?", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - - def test_id_token_code_post_auth_allow(self): - """ - Test authorization code is given for an allowed request with response_type: code - """ - self.client.login(username="test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - } - - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -463,9 +335,7 @@ def test_code_post_auth_deny(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -484,9 +354,7 @@ def test_code_post_auth_deny_no_state(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertNotIn("state", response["Location"]) @@ -506,9 +374,7 @@ def test_code_post_auth_bad_responsetype(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?error", response["Location"]) @@ -527,9 +393,7 @@ def test_code_post_auth_forbidden_redirect_uri(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_malicious_redirect_uri(self): @@ -547,9 +411,7 @@ def test_code_post_auth_malicious_redirect_uri(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_allow_custom_redirect_uri_scheme(self): @@ -568,9 +430,7 @@ def test_code_post_auth_allow_custom_redirect_uri_scheme(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -592,9 +452,7 @@ def test_code_post_auth_deny_custom_redirect_uri_scheme(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -617,9 +475,7 @@ def test_code_post_auth_redirection_uri_with_querystring(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) @@ -642,9 +498,7 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -666,13 +520,80 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) -class TestAuthorizationCodeTokenView(BaseTest): +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeView(BaseTest): + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + + def test_id_token_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="test_user", password="123456") + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "openid") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_id_token_code_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org?", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + + +class BaseAuthorizationCodeTokenView(BaseTest): def get_auth(self, scope="read write"): """ Helper method to retrieve a valid authorization code @@ -686,9 +607,7 @@ def get_auth(self, scope="read write"): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) return query_dict["code"].pop() @@ -699,11 +618,7 @@ def generate_pkce_codes(self, algorithm, length=43): code_verifier = get_random_string(length) if algorithm == "S256": code_challenge = ( - base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ) - .decode() - .rstrip("=") + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=") ) else: code_challenge = code_verifier @@ -713,7 +628,7 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): """ Helper method to retrieve a valid authorization code using pkce """ - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", @@ -725,13 +640,13 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): "code_challenge_method": code_challenge_method, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) - oauth2_settings.PKCE_REQUIRED = False return query_dict["code"].pop() + +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) +class TestAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): def test_basic_auth(self): """ Request an access token using basic authentication for client authentication @@ -744,21 +659,15 @@ def test_basic_auth(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_refresh(self): """ @@ -772,13 +681,9 @@ def test_refresh(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -789,27 +694,21 @@ def test_refresh(self): "code": authorization_code, "redirect_uri": "http://example.org", } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) # check refresh token cannot be used twice - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) content = json.loads(response.content.decode("utf-8")) self.assertTrue("invalid_grant" in content.values()) @@ -818,7 +717,7 @@ def test_refresh_with_grace_period(self): """ Request an access token using a refresh token """ - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 + self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() @@ -827,13 +726,9 @@ def test_refresh_with_grace_period(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -844,9 +739,7 @@ def test_refresh_with_grace_period(self): "code": authorization_code, "redirect_uri": "http://example.org", } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) token_request_data = { "grant_type": "refresh_token", @@ -854,9 +747,7 @@ def test_refresh_with_grace_period(self): "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -865,9 +756,7 @@ def test_refresh_with_grace_period(self): first_refresh_token = content["refresh_token"] # check access token returns same data if used twice, see #497 - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) @@ -875,7 +764,6 @@ def test_refresh_with_grace_period(self): # refresh token should be the same as well self.assertTrue("refresh_token" in content) self.assertEqual(content["refresh_token"], first_refresh_token) - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 def test_refresh_invalidates_old_tokens(self): """ @@ -889,13 +777,9 @@ def test_refresh_invalidates_old_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) rt = content["refresh_token"] @@ -906,9 +790,7 @@ def test_refresh_invalidates_old_tokens(self): "refresh_token": rt, "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) refresh_token = RefreshToken.objects.filter(token=rt).first() @@ -927,13 +809,9 @@ def test_refresh_no_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -941,9 +819,7 @@ def test_refresh_no_scopes(self): "grant_type": "refresh_token", "refresh_token": content["refresh_token"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -961,13 +837,9 @@ def test_refresh_bad_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -976,9 +848,7 @@ def test_refresh_bad_scopes(self): "refresh_token": content["refresh_token"], "scope": "read write nuke", } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_refresh_fail_repeating_requests(self): @@ -993,13 +863,9 @@ def test_refresh_fail_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -1008,13 +874,9 @@ def test_refresh_fail_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_refresh_repeating_requests(self): @@ -1022,7 +884,7 @@ def test_refresh_repeating_requests(self): Trying to refresh an access token with the same refresh token more than once succeeds in the grace period and fails outside """ - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 + self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() @@ -1031,13 +893,9 @@ def test_refresh_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -1046,28 +904,19 @@ def test_refresh_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) # try refreshing outside the refresh window, see #497 rt = RefreshToken.objects.get(token=content["refresh_token"]) self.assertIsNotNone(rt.revoked) - rt.revoked = timezone.now() - datetime.timedelta( - minutes=10 - ) # instead of mocking out datetime + rt.revoked = timezone.now() - datetime.timedelta(minutes=10) # instead of mocking out datetime rt.save() - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 def test_refresh_repeating_requests_non_rotating_tokens(self): """ @@ -1081,13 +930,9 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -1096,19 +941,13 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - oauth2_settings.ROTATE_REFRESH_TOKEN = False + self.oauth2_settings.ROTATE_REFRESH_TOKEN = False - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - oauth2_settings.ROTATE_REFRESH_TOKEN = True - def test_basic_auth_bad_authcode(self): """ Request an access token using a bad authorization code @@ -1120,13 +959,9 @@ def test_basic_auth_bad_authcode(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): @@ -1135,18 +970,10 @@ def test_basic_auth_bad_granttype(self): """ self.client.login(username="test_user", password="123456") - token_request_data = { - "grant_type": "UNKNOWN", - "code": "BLAH", - "redirect_uri": "http://example.org", - } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_grant_expired(self): @@ -1169,13 +996,9 @@ def test_basic_auth_grant_expired(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): @@ -1192,9 +1015,7 @@ def test_basic_auth_bad_secret(self): } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_basic_auth_wrong_auth_type(self): @@ -1210,17 +1031,13 @@ def test_basic_auth_wrong_auth_type(self): "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format( - self.application.client_id, self.application.client_secret - ) + user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_request_body_params(self): @@ -1238,17 +1055,13 @@ def test_request_body_params(self): "client_secret": self.application.client_secret, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public(self): """ @@ -1267,115 +1080,69 @@ def test_public(self): "client_id": self.application.client_id, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) - - def test_id_token_public(self): - """ - Request an access token using client_type: public - """ - self.client.login(username="test_user", password="123456") - - self.application.client_type = Application.CLIENT_PUBLIC - self.application.save() - authorization_code = self.get_auth(scope="openid") - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org", - "client_id": self.application.client_id, - "scope": "openid", - } - - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid") - self.assertIn("access_token", content) - self.assertIn("id_token", content) - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_S256_authorize_get(self): """ Request an access token using client_type: public and PKCE enabled. Tests if the authorize get is successfull - for the S256 algorithm + for the S256 algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode( - { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - oauth2_settings.PKCE_REQUIRED = False + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertContains(response, 'value="S256"', count=1, status_code=200) + self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) def test_public_pkce_plain_authorize_get(self): """ Request an access token using client_type: public and PKCE enabled. Tests if the authorize get is successfull - for the plain algorithm + for the plain algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode( - { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - "code_challenge": code_challenge, - "code_challenge_method": "plain", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + "code_challenge_method": "plain", + } - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - oauth2_settings.PKCE_REQUIRED = False + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertContains(response, 'value="plain"', count=1, status_code=200) + self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) def test_public_pkce_S256(self): """ @@ -1388,7 +1155,7 @@ def test_public_pkce_S256(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1398,18 +1165,13 @@ def test_public_pkce_S256(self): "code_verifier": code_verifier, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) - oauth2_settings.PKCE_REQUIRED = False + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_plain(self): """ @@ -1422,7 +1184,7 @@ def test_public_pkce_plain(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1432,18 +1194,13 @@ def test_public_pkce_plain(self): "code_verifier": code_verifier, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) - oauth2_settings.PKCE_REQUIRED = False + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_invalid_algorithm(self): """ @@ -1455,28 +1212,22 @@ def test_public_pkce_invalid_algorithm(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("invalid") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode( - { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - "code_challenge": code_challenge, - "code_challenge_method": "invalid", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + "code_challenge_method": "invalid", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_missing_code_challenge(self): """ @@ -1489,27 +1240,21 @@ def test_public_pkce_missing_code_challenge(self): self.application.skip_authorization = True self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode( - { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - "code_challenge_method": "S256", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge_method": "S256", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_missing_code_challenge_method(self): """ @@ -1521,26 +1266,20 @@ def test_public_pkce_missing_code_challenge_method(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode( - { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - "code_challenge": code_challenge, - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_S256_invalid_code_verifier(self): """ @@ -1553,7 +1292,7 @@ def test_public_pkce_S256_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1563,11 +1302,8 @@ def test_public_pkce_S256_invalid_code_verifier(self): "code_verifier": "invalid", } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain_invalid_code_verifier(self): """ @@ -1580,7 +1316,7 @@ def test_public_pkce_plain_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1590,11 +1326,8 @@ def test_public_pkce_plain_invalid_code_verifier(self): "code_verifier": "invalid", } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_S256_missing_code_verifier(self): """ @@ -1607,7 +1340,7 @@ def test_public_pkce_S256_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1616,11 +1349,8 @@ def test_public_pkce_S256_missing_code_verifier(self): "client_id": self.application.client_id, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain_missing_code_verifier(self): """ @@ -1633,7 +1363,7 @@ def test_public_pkce_plain_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1642,11 +1372,8 @@ def test_public_pkce_plain_missing_code_verifier(self): "client_id": self.application.client_id, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_malicious_redirect_uri(self): """ @@ -1666,9 +1393,7 @@ def test_malicious_redirect_uri(self): "client_id": self.application.client_id, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") @@ -1692,9 +1417,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1704,21 +1427,15 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ @@ -1735,9 +1452,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1747,13 +1462,9 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") @@ -1781,9 +1492,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1793,70 +1502,15 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) - - def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( - self, - ): - """ - Tests code exchange succeed when redirect uri matches the one used for code request - """ - self.client.login(username="test_user", password="123456") - self.application.redirect_uris = "http://localhost http://example.com?foo=bar" - self.application.save() - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.com?bar=baz&foo=bar", - "response_type": "code", - "allow": True, - } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) - query_dict = parse_qs(urlparse(response["Location"]).query) - authorization_code = query_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar", - } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) - - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid") - self.assertIn("access_token", content) - self.assertIn("id_token", content) - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_oob_as_html(self): """ @@ -1902,7 +1556,7 @@ def test_oob_as_html(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_oob_as_json(self): """ @@ -1919,9 +1573,7 @@ def test_oob_as_json(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) self.assertEqual(response.status_code, 200) self.assertRegex(response["Content-Type"], "^application/json") @@ -1938,19 +1590,136 @@ def test_oob_as_json(self): "client_secret": self.application.client_secret, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): + def setUp(self): + super().setUp() + self.application.algorithm = Application.RS256_ALGORITHM + self.application.save() + + def test_id_token_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "scope": "openid", + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( + self, + ): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeHSAlgorithm(BaseAuthorizationCodeTokenView): + def setUp(self): + super().setUp() + self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + self.application.algorithm = Application.HS256_ALGORITHM + self.application.save() + + def test_id_token(self): + """ + Request an access token using an HS256 application + """ + self.client.login(username="test_user", password="123456") + + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "scope": "openid", + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = response.json() + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + # Check decoding JWT using HS256 + key = self.application.jwk_key + assert key.key_type == "oct" + jwt_token = jwt.JWT(key=key, jwt=content["id_token"]) + claims = json.loads(jwt_token.claims) + assert claims["sub"] == "1" + + +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestAuthorizationCodeProtectedResource(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") @@ -1964,9 +1733,7 @@ def test_resource_access_allowed(self): "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1976,13 +1743,9 @@ def test_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] @@ -1997,6 +1760,25 @@ def test_resource_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") + def test_resource_access_deny(self): + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "faketoken", + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response.status_code, 403) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeProtectedResource(BaseTest): + def setUp(self): + super().setUp() + self.application.algorithm = Application.RS256_ALGORITHM + self.application.save() + def test_id_token_resource_access_allowed(self): self.client.login(username="test_user", password="123456") @@ -2009,9 +1791,7 @@ def test_id_token_resource_access_allowed(self): "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -2021,13 +1801,9 @@ def test_id_token_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] id_token = content["id_token"] @@ -2054,39 +1830,23 @@ def test_id_token_resource_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") - def test_resource_access_deny(self): - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + "faketoken", - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response.status_code, 403) - +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestDefaultScopes(BaseTest): def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes """ self.client.login(username="test_user", password="123456") - oauth2_settings._DEFAULT_SCOPES = ["read"] - query_string = urlencode( - { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "redirect_uri": "http://example.org", - } - ) - url = "{url}?{qs}".format( - url=reverse("oauth2_provider:authorize"), qs=query_string - ) + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "redirect_uri": "http://example.org", + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid @@ -2097,4 +1857,3 @@ def test_pre_auth_default_scopes(self): self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read") self.assertEqual(form["client_id"].value(), self.application.client_id) - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 09401cf..8b9aa3b 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,6 +1,7 @@ import json from urllib.parse import quote_plus +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse @@ -10,10 +11,10 @@ from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView from oauth2_provider.views.mixins import OAuthLibMixin +from . import presets from .utils import get_basic_auth_header @@ -28,6 +29,8 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -41,9 +44,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, ) - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - def tearDown(self): self.application.delete() self.test_user.delete() @@ -105,7 +105,7 @@ class TestExtendedRequest(BaseTest): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() - super(TestExtendedRequest, cls).setUpClass() + super().setUpClass() def test_extended_request(self): class TestView(OAuthLibMixin, View): @@ -158,11 +158,7 @@ def test_client_resource_password_based(self): authorization_grant_type=Application.GRANT_PASSWORD, ) - token_request_data = { - "grant_type": "password", - "username": "test_user", - "password": "123456" - } + token_request_data = {"grant_type": "password", "username": "test_user", "password": "123456"} auth_headers = get_basic_auth_header( quote_plus(self.application.client_id), quote_plus(self.application.client_secret) ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 274ecce..ff5deba 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -12,7 +12,6 @@ class CreateApplicationTest(TestCase): - def test_command_creates_application(self): output = StringIO() self.assertEqual(Application.objects.count(), 0) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 0732b29..ce17a89 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -6,7 +6,6 @@ from oauth2_provider.decorators import protected_resource, rw_protected_resource from oauth2_provider.models import get_access_token_model, get_application_model -from oauth2_provider.settings import oauth2_settings Application = get_application_model() @@ -18,7 +17,7 @@ class TestProtectedResourceDecorator(TestCase): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() - super(TestProtectedResourceDecorator, cls).setUpClass() + super().setUpClass() def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") @@ -34,11 +33,9 @@ def setUp(self): scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", - application=self.application + application=self.application, ) - oauth2_settings._SCOPES = ["read", "write"] - def test_access_denied(self): @protected_resource() def view(request, *args, **kwargs): diff --git a/tests/test_generator.py b/tests/test_generator.py index 211713b..cc79280 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,10 +1,7 @@ +import pytest from django.test import TestCase -from oauth2_provider.generators import ( - BaseHashGenerator, ClientIdGenerator, ClientSecretGenerator, - generate_client_id, generate_client_secret -) -from oauth2_provider.settings import oauth2_settings +from oauth2_provider.generators import BaseHashGenerator, generate_client_id, generate_client_secret class MockHashGenerator(BaseHashGenerator): @@ -12,23 +9,20 @@ def hash(self): return 42 +@pytest.mark.usefixtures("oauth2_settings") class TestGenerators(TestCase): - def tearDown(self): - oauth2_settings.CLIENT_ID_GENERATOR_CLASS = ClientIdGenerator - oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = ClientSecretGenerator - def test_generate_client_id(self): - g = oauth2_settings.CLIENT_ID_GENERATOR_CLASS() + g = self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS() self.assertEqual(len(g.hash()), 40) - oauth2_settings.CLIENT_ID_GENERATOR_CLASS = MockHashGenerator + self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS = MockHashGenerator self.assertEqual(generate_client_id(), 42) def test_generate_secret_id(self): - g = oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() + g = self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() self.assertEqual(len(g.hash()), 128) - oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = MockHashGenerator + self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = MockHashGenerator self.assertEqual(generate_client_secret(), 42) def test_basegen_misuse(self): diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 1f45aee..d198988 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -3,20 +3,25 @@ import json from urllib.parse import parse_qs, urlencode, urlparse +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone +from jwcrypto import jwt from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) -from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views import ProtectedResourceView +from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.views import ProtectedResourceView, ScopedProtectedResourceView -from .utils import get_basic_auth_header +from . import presets +from .utils import get_basic_auth_header, spy_on Application = get_application_model() @@ -32,13 +37,21 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +class ScopedResourceView(ScopedProtectedResourceView): + required_scopes = ["read"] + + def get(self, request, *args, **kwargs): + return "This is a protected resource" + + +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application( name="Hybrid Test Application", @@ -48,23 +61,17 @@ def setUp(self): user=self.hy_dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_OPENID_HYBRID, + algorithm=Application.RS256_ALGORITHM, ) self.application.save() - oauth2_settings._SCOPES = ["read", "write", "openid"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - oauth2_settings.SCOPES = { - "read": "Reading scope", - "write": "Writing scope", - "openid": "OpenID connect" - } - def tearDown(self): self.application.delete() self.hy_test_user.delete() self.hy_dev_user.delete() +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestRegressionIssue315Hybrid(BaseTest): """ Test to avoid regression for the issue 315: request object @@ -73,13 +80,15 @@ class TestRegressionIssue315Hybrid(BaseTest): def test_request_is_not_overwritten_code_token(self): self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code token", - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -88,14 +97,16 @@ def test_request_is_not_overwritten_code_token(self): def test_request_is_not_overwritten_code_id_token(self): self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "nonce": "nonce", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "nonce": "nonce", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -104,14 +115,16 @@ def test_request_is_not_overwritten_code_id_token(self): def test_request_is_not_overwritten_code_id_token_token(self): self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token token", - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "nonce": "nonce", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "nonce": "nonce", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -119,6 +132,7 @@ def test_request_is_not_overwritten_code_id_token_token(self): assert "request" not in response.context_data +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestHybridView(BaseTest): def test_skip_authorization_completely(self): """ @@ -128,13 +142,15 @@ def test_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -148,13 +164,15 @@ def test_id_token_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -166,17 +184,19 @@ def test_pre_auth_invalid_client(self): """ self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": "fakeclientid", - "response_type": "code", - }) + query_string = urlencode( + { + "client_id": "fakeclientid", + "response_type": "code", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 400) self.assertEqual( response.context_data["url"], - "?error=invalid_request&error_description=Invalid+client_id+parameter+value." + "?error=invalid_request&error_description=Invalid+client_id+parameter+value.", ) def test_pre_auth_valid_client(self): @@ -185,13 +205,15 @@ def test_pre_auth_valid_client(self): """ self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -212,14 +234,16 @@ def test_id_token_pre_auth_valid_client(self): """ self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "nonce": "nonce", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "nonce": "nonce", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -241,13 +265,15 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "custom-scheme://example.com", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -264,20 +290,23 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): def test_pre_auth_approval_prompt(self): tok = AccessToken.objects.create( - user=self.hy_test_user, token="1234567890", + user=self.hy_test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "approval_prompt": "auto", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "approval_prompt": "auto", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) @@ -288,44 +317,50 @@ def test_pre_auth_approval_prompt(self): self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default(self): - oauth2_settings.REQUEST_APPROVAL_PROMPT = "force" - self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") + self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "force" + self.assertEqual(self.oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( - user=self.hy_test_user, token="1234567890", + user=self.hy_test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default_override(self): - oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" + self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( - user=self.hy_test_user, token="1234567890", + user=self.hy_test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) @@ -336,10 +371,12 @@ def test_pre_auth_default_redirect(self): """ self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -354,11 +391,13 @@ def test_pre_auth_forbibben_redirect(self): """ self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "redirect_uri": "http://forbidden.it", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "http://forbidden.it", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -370,10 +409,12 @@ def test_pre_auth_wrong_response_type(self): """ self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "WRONG", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "WRONG", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -753,6 +794,7 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): self.assertEqual(response.status_code, 400) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestHybridTokenView(BaseTest): def get_auth(self, scope="read write"): """ @@ -782,7 +824,7 @@ def test_basic_auth(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -792,7 +834,7 @@ def test_basic_auth(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_basic_auth_bad_authcode(self): """ @@ -803,7 +845,7 @@ def test_basic_auth_bad_authcode(self): token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -816,11 +858,7 @@ def test_basic_auth_bad_granttype(self): """ self.client.login(username="hy_test_user", password="123456") - token_request_data = { - "grant_type": "UNKNOWN", - "code": "BLAH", - "redirect_uri": "http://example.org" - } + token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) @@ -832,14 +870,19 @@ def test_basic_auth_grant_expired(self): """ self.client.login(username="hy_test_user", password="123456") g = Grant( - application=self.application, user=self.hy_test_user, code="BLAH", - expires=timezone.now(), redirect_uri="", scope="") + application=self.application, + user=self.hy_test_user, + code="BLAH", + expires=timezone.now(), + redirect_uri="", + scope="", + ) g.save() token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -856,7 +899,7 @@ def test_basic_auth_bad_secret(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") @@ -873,7 +916,7 @@ def test_basic_auth_wrong_auth_type(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) @@ -906,7 +949,7 @@ def test_request_body_params(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public(self): """ @@ -922,7 +965,7 @@ def test_public(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -931,7 +974,7 @@ def test_public(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_id_token_public(self): """ @@ -959,7 +1002,7 @@ def test_id_token_public(self): self.assertEqual(content["scope"], "openid") self.assertIn("access_token", content) self.assertIn("id_token", content) - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_malicious_redirect_uri(self): """ @@ -976,7 +1019,7 @@ def test_malicious_redirect_uri(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "/../", - "client_id": self.application.client_id + "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1008,7 +1051,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=bar" + "redirect_uri": "http://example.org?foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1018,7 +1061,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ @@ -1043,7 +1086,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=baraa" + "redirect_uri": "http://example.org?foo=baraa", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1078,7 +1121,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar" + "redirect_uri": "http://example.com?bar=baz&foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1088,7 +1131,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): """ @@ -1127,9 +1170,10 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu self.assertEqual(content["scope"], "openid") self.assertIn("access_token", content) self.assertIn("id_token", content) - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestHybridProtectedResource(BaseTest): def test_resource_access_allowed(self): self.client.login(username="hy_test_user", password="123456") @@ -1151,7 +1195,7 @@ def test_resource_access_allowed(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1221,6 +1265,11 @@ def test_id_token_resource_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") + # If the resource requires more scopes than we requested, we should get an error + view = ScopedResourceView.as_view() + response = view(request) + self.assertEqual(response.status_code, 403) + def test_resource_access_deny(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "faketoken", @@ -1233,21 +1282,22 @@ def test_resource_access_deny(self): self.assertEqual(response.status_code, 403) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RO) class TestDefaultScopesHybrid(BaseTest): - def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes """ self.client.login(username="hy_test_user", password="123456") - oauth2_settings._DEFAULT_SCOPES = ["read"] - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code token", - "state": "random_state_string", - "redirect_uri": "http://example.org", - }) + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "redirect_uri": "http://example.org", + } + ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) @@ -1261,4 +1311,121 @@ def test_pre_auth_default_scopes(self): self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read") self.assertEqual(form["client_id"].value(), self.application.client_id) - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_application, client, oidc_key): + client.force_login(test_user) + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={ + "client_id": hybrid_application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "nonce": "random_nonce_string", + "allow": True, + }, + ) + assert auth_rsp.status_code == 302 + auth_data = parse_qs(urlparse(auth_rsp["Location"]).fragment) + assert "code" in auth_data + assert "id_token" in auth_data + # Decode the id token - is the nonce correct + jwt_token = jwt.JWT(key=oidc_key, jwt=auth_data["id_token"][0]) + claims = json.loads(jwt_token.claims) + assert "nonce" in claims + assert claims["nonce"] == "random_nonce_string" + code = auth_data["code"][0] + client.logout() + # Get the token response using the code + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": hybrid_application.client_id, + "client_secret": hybrid_application.client_secret, + "scope": "openid", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + assert "id_token" in token_data + # The nonce should be present in this id token also + jwt_token = jwt.JWT(key=oidc_key, jwt=token_data["id_token"]) + claims = json.loads(jwt_token.claims) + assert "nonce" in claims + assert claims["nonce"] == "random_nonce_string" + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_claims_passed_to_code_generation( + oauth2_settings, test_user, hybrid_application, client, mocker, oidc_key +): + # Add a spy on to OAuth2Validator.finalize_id_token + mocker.patch.object( + OAuth2Validator, + "finalize_id_token", + spy_on(OAuth2Validator.finalize_id_token), + ) + claims = {"id_token": {"email": {"essential": True}}} + client.force_login(test_user) + auth_form_rsp = client.get( + reverse("oauth2_provider:authorize"), + data={ + "client_id": hybrid_application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "nonce": "random_nonce_string", + "claims": json.dumps(claims), + }, + ) + # Check that claims has made it in to the form to be submitted + assert auth_form_rsp.status_code == 200 + form_initial_data = auth_form_rsp.context_data["form"].initial + assert "claims" in form_initial_data + assert json.loads(form_initial_data["claims"]) == claims + # Filter out not specified values + form_data = {key: value for key, value in form_initial_data.items() if value is not None} + # Now submitting the form (with allow=True) should persist requested claims + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={"allow": True, **form_data}, + ) + assert auth_rsp.status_code == 302 + auth_data = parse_qs(urlparse(auth_rsp["Location"]).fragment) + assert "code" in auth_data + assert "id_token" in auth_data + assert OAuth2Validator.finalize_id_token.spy.call_count == 1 + oauthlib_request = OAuth2Validator.finalize_id_token.spy.call_args[0][4] + assert oauthlib_request.claims == claims + assert Grant.objects.get().claims == json.dumps(claims) + OAuth2Validator.finalize_id_token.spy.reset_mock() + + # Get the token response using the code + client.logout() + code = auth_data["code"][0] + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": hybrid_application.client_id, + "client_secret": hybrid_application.client_secret, + "scope": "openid", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + assert "id_token" in token_data + assert OAuth2Validator.finalize_id_token.spy.call_count == 1 + oauthlib_request = OAuth2Validator.finalize_id_token.spy.call_args[0][4] + assert oauthlib_request.claims == claims diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 4e8879a..a586340 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,15 +1,17 @@ import json -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import parse_qs, urlparse +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse -from jwcrypto import jwk, jwt +from jwcrypto import jwt from oauth2_provider.models import get_application_model -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView +from . import presets + Application = get_application_model() UserModel = get_user_model() @@ -21,6 +23,7 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -35,36 +38,27 @@ def setUp(self): authorization_grant_type=Application.GRANT_IMPLICIT, ) - oauth2_settings._SCOPES = ["read", "write", "openid"] - oauth2_settings._DEFAULT_SCOPES = ["read"] - oauth2_settings.SCOPES = { - "read": "Reading scope", - "write": "Writing scope", - "openid": "OpenID connect" - } - self.key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) - def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitAuthorizationCodeView(BaseTest): def test_pre_auth_valid_client_default_scopes(self): """ Test response for a valid client_id with response_type: token and default_scopes """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "redirect_uri": "http://example.org", - }) + } - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) self.assertIn("form", response.context) @@ -77,16 +71,15 @@ def test_pre_auth_valid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid @@ -104,13 +97,12 @@ def test_pre_auth_invalid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": "fakeclientid", "response_type": "token", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_pre_auth_default_redirect(self): @@ -119,13 +111,12 @@ def test_pre_auth_default_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) form = response.context["form"] @@ -137,14 +128,13 @@ def test_pre_auth_forbibben_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "redirect_uri": "http://forbidden.it", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_post_auth_allow(self): @@ -176,17 +166,15 @@ def test_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) @@ -252,6 +240,7 @@ def test_implicit_fails_when_redirect_uri_path_is_invalid(self): self.assertEqual(response.status_code, 400) +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitTokenView(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") @@ -282,7 +271,14 @@ def test_resource_access_allowed(self): self.assertEqual(response, "This is a protected resource") +@pytest.mark.usefixtures("oidc_key") +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOpenIDConnectImplicitFlow(BaseTest): + def setUp(self): + super().setUp() + self.application.algorithm = Application.RS256_ALGORITHM + self.application.save() + def test_id_token_post_auth_allow(self): """ Test authorization code is given for an allowed request with response_type: id_token @@ -322,18 +318,16 @@ def test_id_token_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "id_token", "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertNotIn("access_token=", response["Location"]) @@ -356,17 +350,15 @@ def test_id_token_skip_authorization_completely_missing_nonce(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "id_token", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) @@ -430,18 +422,16 @@ def test_access_token_and_id_token_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "id_token token", "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index db37f6c..8b2a6da 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -1,10 +1,13 @@ import calendar import datetime -from django.conf.urls import include, url +import pytest +from django.conf import settings +from django.conf.urls import include from django.contrib.auth import get_user_model from django.http import HttpResponse from django.test import TestCase, override_settings +from django.urls import path from django.utils import timezone from oauthlib.common import Request @@ -13,6 +16,8 @@ from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ScopedProtectedResourceView +from . import presets + try: from unittest import mock @@ -41,6 +46,7 @@ def mocked_requests_post(url, data, *args, **kwargs): """ Mock the response from the authentication server """ + class MockResponse: def __init__(self, json_data, status_code): self.json_data = json_data @@ -50,30 +56,39 @@ def json(self): return self.json_data if "token" in data and data["token"] and data["token"] != "12345678900": - return MockResponse({ - "active": True, - "scope": "read write dolphin", - "client_id": "client_id_{}".format(data["token"]), - "username": "{}_user".format(data["token"]), - "exp": int(calendar.timegm(exp.timetuple())), - }, 200) + return MockResponse( + { + "active": True, + "scope": "read write dolphin", + "client_id": "client_id_{}".format(data["token"]), + "username": "{}_user".format(data["token"]), + "exp": int(calendar.timegm(exp.timetuple())), + }, + 200, + ) - return MockResponse({ - "active": False, - }, 200) + return MockResponse( + { + "active": False, + }, + 200, + ) urlpatterns = [ - url(r"^oauth2/", include("oauth2_provider.urls")), - url(r"^oauth2-test-resource/$", ScopeResourceView.as_view()), + path("oauth2/", include("oauth2_provider.urls")), + path("oauth2-test-resource/", ScopeResourceView.as_view()), ] @override_settings(ROOT_URLCONF=__name__) +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) class TestTokenIntrospectionAuth(TestCase): """ Tests for Authorization through token introspection """ + def setUp(self): self.validator = OAuth2Validator() self.request = mock.MagicMock(wraps=Request) @@ -90,29 +105,24 @@ def setUp(self): ) self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, token="12345678900", + user=self.resource_server_user, + token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="introspection" + scope="introspection", ) self.invalid_token = AccessToken.objects.create( - user=self.resource_server_user, token="12345678901", + user=self.resource_server_user, + token="12345678901", application=self.application, expires=timezone.now() + datetime.timedelta(days=-1), - scope="read write dolphin" + scope="read write dolphin", ) - oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = "http://example.org/introspection" - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token - oauth2_settings.READ_SCOPE = "read" - oauth2_settings.WRITE_SCOPE = "write" + self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = None - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = None self.resource_server_token.delete() self.application.delete() AccessToken.objects.all().delete() @@ -125,9 +135,9 @@ def test_get_token_from_authentication_server_not_existing_token(self, mock_get) """ token = self.validator._get_token_from_authentication_server( self.resource_server_token.token, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsNone(token) @@ -138,14 +148,33 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): """ token = self.validator._get_token_from_authentication_server( "foo", - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsInstance(token, AccessToken) self.assertEqual(token.user.username, "foo_user") self.assertEqual(token.scope, "read write dolphin") + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_get_token_from_authentication_server_expires_timezone(self, mock_get): + """ + Test method _get_token_from_authentication_server for projects with USE_TZ False + """ + settings_use_tz_backup = settings.USE_TZ + settings.USE_TZ = False + try: + self.validator._get_token_from_authentication_server( + "foo", + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, + ) + except ValueError as exception: + self.fail(str(exception)) + finally: + settings.USE_TZ = settings_use_tz_backup + @mock.patch("requests.post", side_effect=mocked_requests_post) def test_validate_bearer_token(self, mock_get): """ diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index a06a73e..0f68320 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -1,13 +1,16 @@ import calendar import datetime +import pytest from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model -from oauth2_provider.settings import oauth2_settings + +from . import presets +from .utils import get_basic_auth_header Application = get_application_model() @@ -15,10 +18,13 @@ UserModel = get_user_model() +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) class TestTokenIntrospectionViews(TestCase): """ Tests for Authorized Token Introspection Views """ + def setUp(self): self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") @@ -32,46 +38,46 @@ def setUp(self): ) self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, token="12345678900", + user=self.resource_server_user, + token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="introspection" + scope="introspection", ) self.valid_token = AccessToken.objects.create( - user=self.test_user, token="12345678901", + user=self.test_user, + token="12345678901", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write dolphin" + scope="read write dolphin", ) self.invalid_token = AccessToken.objects.create( - user=self.test_user, token="12345678902", + user=self.test_user, + token="12345678902", application=self.application, expires=timezone.now() + datetime.timedelta(days=-1), - scope="read write dolphin" + scope="read write dolphin", ) self.token_without_user = AccessToken.objects.create( - user=None, token="12345678903", + user=None, + token="12345678903", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write dolphin" + scope="read write dolphin", ) self.token_without_app = AccessToken.objects.create( - user=self.test_user, token="12345678904", + user=self.test_user, + token="12345678904", application=None, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write dolphin" + scope="read write dolphin", ) - oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] - oauth2_settings.READ_SCOPE = "read" - oauth2_settings.WRITE_SCOPE = "write" - def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] AccessToken.objects.all().delete() Application.objects.all().delete() UserModel.objects.all().delete() @@ -92,20 +98,22 @@ def test_view_get_valid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.valid_token.scope, - "client_id": self.valid_token.application.client_id, - "username": self.valid_token.user.get_username(), - "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) def test_view_get_valid_token_without_user(self): """ @@ -116,19 +124,21 @@ def test_view_get_valid_token_without_user(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.token_without_user.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.token_without_user.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.token_without_user.scope, - "client_id": self.token_without_user.application.client_id, - "exp": int(calendar.timegm(self.token_without_user.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.token_without_user.scope, + "client_id": self.token_without_user.application.client_id, + "exp": int(calendar.timegm(self.token_without_user.expires.timetuple())), + }, + ) def test_view_get_valid_token_without_app(self): """ @@ -139,19 +149,21 @@ def test_view_get_valid_token_without_app(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.token_without_app.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.token_without_app.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.token_without_app.scope, - "username": self.token_without_app.user.get_username(), - "exp": int(calendar.timegm(self.token_without_app.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.token_without_app.scope, + "username": self.token_without_app.user.get_username(), + "exp": int(calendar.timegm(self.token_without_app.expires.timetuple())), + }, + ) def test_view_get_invalid_token(self): """ @@ -162,16 +174,18 @@ def test_view_get_invalid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.invalid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.invalid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) def test_view_get_notexisting_token(self): """ @@ -182,16 +196,18 @@ def test_view_get_notexisting_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": "kaudawelsch"}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers + ) self.assertEqual(response.status_code, 401) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) def test_view_post_valid_token(self): """ @@ -202,20 +218,22 @@ def test_view_post_valid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.valid_token.scope, - "client_id": self.valid_token.application.client_id, - "username": self.valid_token.user.get_username(), - "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) def test_view_post_invalid_token(self): """ @@ -226,16 +244,18 @@ def test_view_post_invalid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": self.invalid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.invalid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) def test_view_post_notexisting_token(self): """ @@ -246,13 +266,85 @@ def test_view_post_notexisting_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": "kaudawelsch"}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers + ) self.assertEqual(response.status_code, 401) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) + + def test_view_post_valid_client_creds_basic_auth(self): + """Test HTTP basic auth working""" + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + response = self.client.post( + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) + + def test_view_post_invalid_client_creds_basic_auth(self): + """Must fail for invalid client credentials""" + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + "_so_wrong" + ) + response = self.client.post( + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) + self.assertEqual(response.status_code, 403) + + def test_view_post_valid_client_creds_plaintext(self): + """Test introspecting with credentials in request body""" + response = self.client.post( + reverse("oauth2_provider:introspect"), + { + "token": self.valid_token.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + }, + ) + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) + + def test_view_post_invalid_client_creds_plaintext(self): + """Must fail for invalid creds in request body.""" + response = self.client.post( + reverse("oauth2_provider:introspect"), + { + "token": self.valid_token.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret + "_so_wrong", + }, + ) + self.assertEqual(response.status_code, 403) + + def test_select_related_in_view_for_less_db_queries(self): + with self.assertNumQueries(1): + self.client.post(reverse("oauth2_provider:introspect")) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 79988c9..1294b75 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -1,4 +1,8 @@ +import logging + +import pytest from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponse from django.test import RequestFactory, TestCase from django.views.generic import View from oauthlib.oauth2 import Server @@ -6,44 +10,73 @@ from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.views.mixins import ( - OAuthLibMixin, ProtectedResourceMixin, ScopedResourceMixin + OAuthLibMixin, + OIDCOnlyMixin, + ProtectedResourceMixin, + ScopedResourceMixin, ) +from . import presets + +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() - super(BaseTest, cls).setUpClass() + super().setUpClass() class TestOAuthLibMixin(BaseTest): - def test_missing_oauthlib_backend_class(self): + def test_missing_oauthlib_backend_class_uses_fallback(self): + class CustomOauthLibBackend: + def __init__(self, *args, **kwargs): + pass + + self.oauth2_settings.OAUTH2_BACKEND_CLASS = CustomOauthLibBackend + class TestView(OAuthLibMixin, View): server_class = Server validator_class = OAuth2Validator test_view = TestView() - self.assertRaises(ImproperlyConfigured, test_view.get_oauthlib_backend_class) + self.assertEqual(CustomOauthLibBackend, test_view.get_oauthlib_backend_class()) + core = test_view.get_oauthlib_core() + self.assertTrue(isinstance(core, CustomOauthLibBackend)) + + def test_missing_server_class_uses_fallback(self): + class CustomServer: + def __init__(self, *args, **kwargs): + pass + + self.oauth2_settings.OAUTH2_SERVER_CLASS = CustomServer - def test_missing_server_class(self): class TestView(OAuthLibMixin, View): validator_class = OAuth2Validator oauthlib_backend_class = OAuthLibCore test_view = TestView() - self.assertRaises(ImproperlyConfigured, test_view.get_server) + self.assertEqual(CustomServer, test_view.get_server_class()) + core = test_view.get_oauthlib_core() + self.assertTrue(isinstance(core.server, CustomServer)) + + def test_missing_validator_class_uses_fallback(self): + class CustomValidator: + pass + + self.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator - def test_missing_validator_class(self): class TestView(OAuthLibMixin, View): server_class = Server oauthlib_backend_class = OAuthLibCore test_view = TestView() - self.assertRaises(ImproperlyConfigured, test_view.get_server) + self.assertEqual(CustomValidator, test_view.get_validator_class()) + core = test_view.get_oauthlib_core() + self.assertTrue(isinstance(core.server.request_validator, CustomValidator)) def test_correct_server(self): class TestView(OAuthLibMixin, View): @@ -58,7 +91,7 @@ class TestView(OAuthLibMixin, View): self.assertIsInstance(test_view.get_server(), Server) def test_custom_backend(self): - class AnotherOauthLibBackend(object): + class AnotherOauthLibBackend: pass class TestView(OAuthLibMixin, View): @@ -70,9 +103,7 @@ class TestView(OAuthLibMixin, View): request.user = "fake" test_view = TestView() - self.assertEqual( - test_view.get_oauthlib_backend_class(), AnotherOauthLibBackend - ) + self.assertEqual(test_view.get_oauthlib_backend_class(), AnotherOauthLibBackend) class TestScopedResourceMixin(BaseTest): @@ -103,3 +134,38 @@ class TestView(ProtectedResourceMixin, View): view = TestView.as_view() response = view(request) self.assertEqual(response.status_code, 200) + + +@pytest.fixture +def oidc_only_view(): + class TView(OIDCOnlyMixin, View): + def get(self, *args, **kwargs): + return HttpResponse("OK") + + return TView.as_view() + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_oidc_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): + assert oauth2_settings.OIDC_ENABLED + rsp = oidc_only_view(rf.get("/")) + assert rsp.status_code == 200 + assert rsp.content.decode("utf-8") == "OK" + + +def test_oidc_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc_only_view): + assert oauth2_settings.OIDC_ENABLED is False + settings.DEBUG = True + with pytest.raises(ImproperlyConfigured) as exc: + oidc_only_view(rf.get("/")) + assert "OIDC views are not enabled" in str(exc.value) + + +def test_oidc_only_mixin_oidc_disabled_no_debug(oauth2_settings, rf, settings, oidc_only_view, caplog): + assert oauth2_settings.OIDC_ENABLED is False + settings.DEBUG = False + with caplog.at_level(logging.WARNING, logger="oauth2_provider"): + rsp = oidc_only_view(rf.get("/")) + assert rsp.status_code == 404 + assert len(caplog.records) == 1 + assert "OIDC views are not enabled" in caplog.records[0].message diff --git a/tests/test_models.py b/tests/test_models.py index 95e8eb4..7b37486 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,10 +6,15 @@ from django.utils import timezone from oauth2_provider.models import ( - clear_expired, get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + clear_expired, + get_access_token_model, + get_application_model, + get_grant_model, + get_id_token_model, + get_refresh_token_model, ) -from oauth2_provider.settings import oauth2_settings + +from . import presets Application = get_application_model() @@ -17,13 +22,18 @@ AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() +IDToken = get_id_token_model() -class TestModels(TestCase): - +class BaseTestModels(TestCase): def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + def tearDown(self): + self.user.delete() + + +class TestModels(BaseTestModels): def test_allow_scopes(self): self.client.login(username="test_user", password="123456") app = Application.objects.create( @@ -34,13 +44,7 @@ def test_allow_scopes(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - access_token = AccessToken( - user=self.user, - scope="read write", - expires=0, - token="", - application=app - ) + access_token = AccessToken(user=self.user, scope="read write", expires=0, token="", application=app) self.assertTrue(access_token.allow_scopes(["read", "write"])) self.assertTrue(access_token.allow_scopes(["write", "read"])) @@ -93,21 +97,9 @@ def test_scopes_property(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - access_token = AccessToken( - user=self.user, - scope="read write", - expires=0, - token="", - application=app - ) + access_token = AccessToken(user=self.user, scope="read write", expires=0, token="", application=app) - access_token2 = AccessToken( - user=self.user, - scope="write", - expires=0, - token="", - application=app - ) + access_token2 = AccessToken(user=self.user, scope="write", expires=0, token="", application=app) self.assertEqual(access_token.scopes, {"read": "Reading scope", "write": "Writing scope"}) self.assertEqual(access_token2.scopes, {"write": "Writing scope"}) @@ -117,13 +109,10 @@ def test_scopes_property(self): OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication", OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL="tests.SampleAccessToken", OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", - OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant" + OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant", ) -class TestCustomModels(TestCase): - - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - +@pytest.mark.usefixtures("oauth2_settings") +class TestCustomModels(BaseTestModels): def test_custom_application_model(self): """ If a custom application model is installed, it should be present in @@ -132,7 +121,8 @@ def test_custom_application_model(self): See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) """ related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:application", related_object_names) @@ -140,22 +130,16 @@ def test_custom_application_model(self): def test_custom_application_model_incorrect_format(self): # Patch oauth2 settings to use a custom Application model - oauth2_settings.APPLICATION_MODEL = "IncorrectApplicationFormat" + self.oauth2_settings.APPLICATION_MODEL = "IncorrectApplicationFormat" self.assertRaises(ValueError, get_application_model) - # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" - def test_custom_application_model_not_installed(self): # Patch oauth2 settings to use a custom Application model - oauth2_settings.APPLICATION_MODEL = "tests.ApplicationNotInstalled" + self.oauth2_settings.APPLICATION_MODEL = "tests.ApplicationNotInstalled" self.assertRaises(LookupError, get_application_model) - # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" - def test_custom_access_token_model(self): """ If a custom access token model is installed, it should be present in @@ -163,7 +147,8 @@ def test_custom_access_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:access_token", related_object_names) @@ -171,22 +156,16 @@ def test_custom_access_token_model(self): def test_custom_access_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.ACCESS_TOKEN_MODEL = "IncorrectAccessTokenFormat" + self.oauth2_settings.ACCESS_TOKEN_MODEL = "IncorrectAccessTokenFormat" self.assertRaises(ValueError, get_access_token_model) - # Revert oauth2 settings - oauth2_settings.ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" - def test_custom_access_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.ACCESS_TOKEN_MODEL = "tests.AccessTokenNotInstalled" + self.oauth2_settings.ACCESS_TOKEN_MODEL = "tests.AccessTokenNotInstalled" self.assertRaises(LookupError, get_access_token_model) - # Revert oauth2 settings - oauth2_settings.ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" - def test_custom_refresh_token_model(self): """ If a custom refresh token model is installed, it should be present in @@ -194,7 +173,8 @@ def test_custom_refresh_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:refresh_token", related_object_names) @@ -202,22 +182,16 @@ def test_custom_refresh_token_model(self): def test_custom_refresh_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom RefreshToken model - oauth2_settings.REFRESH_TOKEN_MODEL = "IncorrectRefreshTokenFormat" + self.oauth2_settings.REFRESH_TOKEN_MODEL = "IncorrectRefreshTokenFormat" self.assertRaises(ValueError, get_refresh_token_model) - # Revert oauth2 settings - oauth2_settings.REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" - def test_custom_refresh_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.REFRESH_TOKEN_MODEL = "tests.RefreshTokenNotInstalled" + self.oauth2_settings.REFRESH_TOKEN_MODEL = "tests.RefreshTokenNotInstalled" self.assertRaises(LookupError, get_refresh_token_model) - # Revert oauth2 settings - oauth2_settings.REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" - def test_custom_grant_model(self): """ If a custom grant model is installed, it should be present in @@ -225,7 +199,8 @@ def test_custom_grant_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:grant", related_object_names) @@ -233,24 +208,31 @@ def test_custom_grant_model(self): def test_custom_grant_model_incorrect_format(self): # Patch oauth2 settings to use a custom Grant model - oauth2_settings.GRANT_MODEL = "IncorrectGrantFormat" + self.oauth2_settings.GRANT_MODEL = "IncorrectGrantFormat" self.assertRaises(ValueError, get_grant_model) - # Revert oauth2 settings - oauth2_settings.GRANT_MODEL = "oauth2_provider.Grant" - def test_custom_grant_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.GRANT_MODEL = "tests.GrantNotInstalled" + self.oauth2_settings.GRANT_MODEL = "tests.GrantNotInstalled" self.assertRaises(LookupError, get_grant_model) - # Revert oauth2 settings - oauth2_settings.GRANT_MODEL = "oauth2_provider.Grant" +class TestGrantModel(BaseTestModels): + def setUp(self): + super().setUp() + self.application = Application.objects.create( + name="Test Application", + redirect_uris="", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) -class TestGrantModel(TestCase): + def tearDown(self): + self.application.delete() + super().tearDown() def test_str(self): grant = Grant(code="test_code") @@ -261,12 +243,26 @@ def test_expires_can_be_none(self): self.assertIsNone(grant.expires) self.assertTrue(grant.is_expired()) + def test_redirect_uri_can_be_longer_than_255_chars(self): + long_redirect_uri = "http://example.com/{}".format("authorized/" * 25) + self.assertTrue(len(long_redirect_uri) > 255) + grant = Grant.objects.create( + user=self.user, + code="test_code", + application=self.application, + expires=timezone.now(), + redirect_uri=long_redirect_uri, + scope="", + ) + grant.refresh_from_db() -class TestAccessTokenModel(TestCase): + # It would be necessary to run test using another DB engine than sqlite + # that transform varchar(255) into text data type. + # https://sqlite.org/datatype3.html#affinity_name_examples + self.assertEqual(grant.redirect_uri, long_redirect_uri) - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") +class TestAccessTokenModel(BaseTestModels): def test_str(self): access_token = AccessToken(token="test_token") self.assertEqual("%s" % access_token, access_token.token) @@ -288,17 +284,16 @@ def test_expires_can_be_none(self): self.assertTrue(access_token.is_expired()) -class TestRefreshTokenModel(TestCase): - +class TestRefreshTokenModel(BaseTestModels): def test_str(self): refresh_token = RefreshToken(token="test_token") self.assertEqual("%s" % refresh_token, refresh_token.token) -class TestClearExpired(TestCase): - +@pytest.mark.usefixtures("oauth2_settings") +class TestClearExpired(BaseTestModels): def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + super().setUp() # Insert two tokens on database. app = Application.objects.create( name="test_app", @@ -315,7 +310,7 @@ def setUp(self): user=self.user, created=timezone.now(), updated=timezone.now(), - ) + ) AccessToken.objects.create( token="666", expires=timezone.now(), @@ -324,14 +319,14 @@ def setUp(self): user=self.user, created=timezone.now(), updated=timezone.now(), - ) + ) def test_clear_expired_tokens(self): - oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 + self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 assert clear_expired() is None def test_clear_expired_tokens_incorect_timetype(self): - oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" + self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" with pytest.raises(ImproperlyConfigured) as excinfo: clear_expired() result = excinfo.value.__class__.__name__ @@ -339,7 +334,7 @@ def test_clear_expired_tokens_incorect_timetype(self): def test_clear_expired_tokens_with_tokens(self): self.client.login(username="test_user", password="123456") - oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 + self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 ttokens = AccessToken.objects.count() expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() assert ttokens == 2 @@ -347,3 +342,93 @@ def test_clear_expired_tokens_with_tokens(self): clear_expired() expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() assert expiredt == 0 + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_id_token_methods(oidc_tokens, rf): + id_token = IDToken.objects.get() + + # Token was just created, so should be valid + assert id_token.is_valid() + + # if expires is None, it should always be expired + # the column is NOT NULL, but could be NULL in sub-classes + id_token.expires = None + assert id_token.is_expired() + + # if no scopes are passed, they should be valid + assert id_token.allow_scopes(None) + + # if the requested scopes are in the token, they should be valid + assert id_token.allow_scopes(["openid"]) + + # if the requested scopes are not in the token, they should not be valid + assert id_token.allow_scopes(["fizzbuzz"]) is False + + # we should be able to get a list of the scopes on the token + assert id_token.scopes == {"openid": "OpenID connect"} + + # the id token should stringify as the JWT token + id_token_str = str(id_token) + assert str(id_token.jti) in id_token_str + assert id_token_str.endswith(str(id_token.user_id)) + + # revoking the token should delete it + id_token.revoke() + assert IDToken.objects.filter(jti=id_token.jti).count() == 0 + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_key(oauth2_settings, application): + # RS256 key + key = application.jwk_key + assert key.key_type == "RSA" + + # RS256 key, but not configured + oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + with pytest.raises(ImproperlyConfigured) as exc: + application.jwk_key + assert "You must set OIDC_RSA_PRIVATE_KEY" in str(exc.value) + + # HS256 key + application.algorithm = Application.HS256_ALGORITHM + key = application.jwk_key + assert key.key_type == "oct" + + # No algorithm + application.algorithm = Application.NO_ALGORITHM + with pytest.raises(ImproperlyConfigured) as exc: + application.jwk_key + assert "This application does not support signed tokens" == str(exc.value) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean(oauth2_settings, application): + # RS256, RSA key is configured + application.clean() + + # RS256, RSA key is not configured + oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + with pytest.raises(ValidationError) as exc: + application.clean() + assert "You must set OIDC_RSA_PRIVATE_KEY" in str(exc.value) + + # HS256 algorithm, auth code + confidential -> allowed + application.algorithm = Application.HS256_ALGORITHM + application.clean() + + # HS256, auth code + public -> forbidden + application.client_type = Application.CLIENT_PUBLIC + with pytest.raises(ValidationError) as exc: + application.clean() + assert "You cannot use HS256" in str(exc.value) + + # HS256, hybrid + confidential -> forbidden + application.client_type = Application.CLIENT_CONFIDENTIAL + application.authorization_grant_type = Application.GRANT_OPENID_HYBRID + with pytest.raises(ValidationError) as exc: + application.clean() + assert "You cannot use HS256" in str(exc.value) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index 0d98dad..acff2ca 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -1,8 +1,10 @@ import json +import pytest from django.test import RequestFactory, TestCase from oauth2_provider.backends import get_oauthlib_core +from oauth2_provider.models import redirect_to_uri_allowed from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore @@ -12,16 +14,16 @@ import mock +@pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackend(TestCase): - def setUp(self): self.factory = RequestFactory() self.oauthlib_core = OAuthLibCore() def test_swappable_server_class(self): - with mock.patch("oauth2_provider.oauth2_backends.oauth2_settings.OAUTH2_SERVER_CLASS"): - oauthlib_core = OAuthLibCore() - self.assertTrue(isinstance(oauthlib_core.server, mock.MagicMock)) + self.oauth2_settings.OAUTH2_SERVER_CLASS = mock.MagicMock + oauthlib_core = OAuthLibCore() + self.assertTrue(isinstance(oauthlib_core.server, mock.MagicMock)) def test_form_urlencoded_extract_params(self): payload = "grant_type=password&username=john&password=123456" @@ -33,11 +35,13 @@ def test_form_urlencoded_extract_params(self): self.assertIn("password=123456", body) def test_application_json_extract_params(self): - payload = json.dumps({ - "grant_type": "password", - "username": "john", - "password": "123456", - }) + payload = json.dumps( + { + "grant_type": "password", + "username": "john", + "password": "123456", + } + ) request = self.factory.post("/o/token/", payload, content_type="application/json") uri, http_method, body, headers = self.oauthlib_core._extract_params(request) @@ -51,6 +55,7 @@ class TestCustomOAuthLibCoreBackend(TestCase): Tests that the public API behaves as expected when we override the OAuthLibCoreBackend core methods. """ + class MyOAuthLibCore(OAuthLibCore): def _get_extra_credentials(self, request): return 1 @@ -65,9 +70,7 @@ def test_create_token_response_gets_extra_credentials(self): payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") - with mock.patch( - "oauthlib.openid.connect.core.endpoints.pre_configured.Server.create_token_response" - ) as create_token_response: + with mock.patch("oauthlib.oauth2.Server.create_token_response") as create_token_response: mocked = mock.MagicMock() create_token_response.return_value = mocked, mocked, mocked core = self.MyOAuthLibCore() @@ -81,11 +84,13 @@ def setUp(self): self.oauthlib_core = JSONOAuthLibCore() def test_application_json_extract_params(self): - payload = json.dumps({ - "grant_type": "password", - "username": "john", - "password": "123456", - }) + payload = json.dumps( + { + "grant_type": "password", + "username": "john", + "password": "123456", + } + ) request = self.factory.post("/o/token/", payload, content_type="application/json") uri, http_method, body, headers = self.oauthlib_core._extract_params(request) @@ -106,3 +111,23 @@ def test_validate_authorization_request_unsafe_query(self): oauthlib_core = get_oauthlib_core() oauthlib_core.verify_request(request, scopes=[]) + + +@pytest.mark.parametrize( + "uri, expected_result", + # localhost is _not_ a loopback URI + [ + ("http://localhost:3456", False), + # only http scheme is supported for loopback URIs + ("https://127.0.0.1:3456", False), + ("http://127.0.0.1:3456", True), + ("http://[::1]", True), + ("http://[::1]:34", True), + ], +) +def test_uri_loopback_redirect_check(uri, expected_result): + allowed_uris = ["http://127.0.0.1", "http://[::1]"] + if expected_result: + assert redirect_to_uri_allowed(uri, allowed_uris) + else: + assert not redirect_to_uri_allowed(uri, allowed_uris) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index d924823..7997d3b 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -1,17 +1,21 @@ import contextlib import datetime +import json +import pytest from django.contrib.auth import get_user_model -from django.test import TransactionTestCase +from django.test import TestCase, TransactionTestCase from django.utils import timezone +from jwcrypto import jwt from oauthlib.common import Request from oauth2_provider.exceptions import FatalClientError -from oauth2_provider.models import ( - get_access_token_model, get_application_model, get_refresh_token_model -) +from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model +from oauth2_provider.oauth2_backends import get_oauthlib_core from oauth2_provider.oauth2_validators import OAuth2Validator +from . import presets + try: from unittest import mock @@ -46,8 +50,12 @@ def setUp(self): self.request.grant_type = "not client" self.validator = OAuth2Validator() self.application = Application.objects.create( - client_id="client_id", client_secret="client_secret", user=self.user, - client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD) + client_id="client_id", + client_secret="client_secret", + user=self.user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_PASSWORD, + ) self.request.client = self.application def tearDown(self): @@ -163,13 +171,10 @@ def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(sel token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), - application=self.application + application=self.application, ) refresh_token = RefreshToken.objects.create( - access_token=access_token, - token="abc", - user=self.user, - application=self.application + access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token token = { @@ -196,13 +201,10 @@ def test_save_bearer_token__checks_to_rotate_tokens(self): token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), - application=self.application + application=self.application, ) refresh_token = RefreshToken.objects.create( - access_token=access_token, - token="abc", - user=self.user, - application=self.application + access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token token = { @@ -234,13 +236,10 @@ def test_save_bearer_token__with_new_token_equal_to_existing_token__revokes_old_ token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), - application=self.application + application=self.application, ) refresh_token = RefreshToken.objects.create( - access_token=access_token, - token="abc", - user=self.user, - application=self.application + access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token @@ -318,7 +317,9 @@ class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): def setUp(self): self.user = UserModel.objects.create_user( - "user", "test@example.com", "123456", + "user", + "test@example.com", + "123456", ) self.request = mock.MagicMock(wraps=Request) self.request.user = self.user @@ -340,13 +341,20 @@ def test_validate_bearer_token_does_not_add_error_when_no_token_is_provided(self def test_validate_bearer_token_adds_error_to_the_request_when_an_invalid_token_is_provided(self): access_token = mock.MagicMock(token="some_invalid_token") - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, [], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "invalid_token", - "error_description": "The access token is invalid.", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + [], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "invalid_token", + "error_description": "The access token is invalid.", + }, + ) def test_validate_bearer_token_adds_error_to_the_request_when_an_expired_token_is_provided(self): access_token = AccessToken.objects.create( @@ -355,13 +363,20 @@ def test_validate_bearer_token_adds_error_to_the_request_when_an_expired_token_i expires=timezone.now() - datetime.timedelta(seconds=1), application=self.application, ) - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, [], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "invalid_token", - "error_description": "The access token has expired.", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + [], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "invalid_token", + "error_description": "The access token has expired.", + }, + ) def test_validate_bearer_token_adds_error_to_the_request_when_a_valid_token_has_insufficient_scope(self): access_token = AccessToken.objects.create( @@ -370,13 +385,20 @@ def test_validate_bearer_token_adds_error_to_the_request_when_a_valid_token_has_ expires=timezone.now() + datetime.timedelta(seconds=1), application=self.application, ) - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, ["some_extra_scope"], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "insufficient_scope", - "error_description": "The access token is valid but does not have enough scope.", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + ["some_extra_scope"], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "insufficient_scope", + "error_description": "The access token is valid but does not have enough scope.", + }, + ) def test_validate_bearer_token_adds_error_to_the_request_when_a_invalid_custom_token_is_provided(self): access_token = AccessToken.objects.create( @@ -386,9 +408,115 @@ def test_validate_bearer_token_adds_error_to_the_request_when_a_invalid_custom_t application=self.application, ) with always_invalid_token(): - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, [], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "invalid_token", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + [], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "invalid_token", + }, + ) + + +class TestOAuth2ValidatorErrorResourceToken(TestCase): + """The following tests check logger information when response from oauth2 + is unsuccessful. + """ + + def setUp(self): + self.token = "test_token" + self.introspection_url = "http://example.com/token/introspection/" + self.introspection_token = "test_introspection_token" + self.validator = OAuth2Validator() + + def test_response_when_auth_server_response_return_404(self): + with self.assertLogs(logger="oauth2_provider") as mock_log: + self.validator._get_token_from_authentication_server( + self.token, self.introspection_url, self.introspection_token, None + ) + self.assertIn( + "ERROR:oauth2_provider:Introspection: Failed to " + "get a valid response from authentication server. " + "Status code: 404, Reason: " + "Not Found.\nNoneType: None", + mock_log.output, + ) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_oidc_endpoint_generation(oauth2_settings, rf): + oauth2_settings.OIDC_ISS_ENDPOINT = "" + django_request = rf.get("/") + request = Request("/", headers=django_request.META) + validator = OAuth2Validator() + oidc_issuer_endpoint = validator.get_oidc_issuer_endpoint(request) + assert oidc_issuer_endpoint == "http://testserver/o" + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_oidc_endpoint_generation_ssl(oauth2_settings, rf, settings): + oauth2_settings.OIDC_ISS_ENDPOINT = "" + django_request = rf.get("/", secure=True) + # Calling the settings method with a django https request should generate a https url + oidc_issuer_endpoint = oauth2_settings.oidc_issuer(django_request) + assert oidc_issuer_endpoint == "https://testserver/o" + + # Should also work with an oauthlib request (via validator) + core = get_oauthlib_core() + uri, http_method, body, headers = core._extract_params(django_request) + request = Request(uri=uri, http_method=http_method, body=body, headers=headers) + validator = OAuth2Validator() + oidc_issuer_endpoint = validator.get_oidc_issuer_endpoint(request) + assert oidc_issuer_endpoint == "https://testserver/o" + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_get_jwt_bearer_token(oauth2_settings, mocker): + # oauthlib instructs us to make get_jwt_bearer_token call get_id_token + request = mocker.MagicMock(wraps=Request) + validator = OAuth2Validator() + mock_get_id_token = mocker.patch.object(validator, "get_id_token") + validator.get_jwt_bearer_token(None, None, request) + assert mock_get_id_token.call_count == 1 + assert mock_get_id_token.call_args[0] == (None, None, request) + assert mock_get_id_token.call_args[1] == {} + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_expired_jwt(oauth2_settings, mocker, oidc_tokens): + mocker.patch("oauth2_provider.oauth2_validators.jwt.JWT", side_effect=jwt.JWTExpired) + validator = OAuth2Validator() + status = validator.validate_id_token(oidc_tokens.id_token, ["openid"], mocker.sentinel.request) + assert status is False + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_no_token(oauth2_settings, mocker): + validator = OAuth2Validator() + status = validator.validate_id_token("", ["openid"], mocker.sentinel.request) + assert status is False + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_app_removed(oauth2_settings, mocker, oidc_tokens): + oidc_tokens.application.delete() + validator = OAuth2Validator() + status = validator.validate_id_token(oidc_tokens.id_token, ["openid"], mocker.sentinel.request) + assert status is False + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_bad_token_no_aud(oauth2_settings, mocker, oidc_key): + token = jwt.JWT(header=json.dumps({"alg": "RS256"}), claims=json.dumps({"bad": "token"})) + token.make_signed_token(oidc_key) + validator = OAuth2Validator() + status = validator.validate_id_token(token.serialize(), ["openid"], mocker.sentinel.request) + assert status is False diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 43e46d2..5cbae54 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -1,17 +1,48 @@ -from __future__ import unicode_literals - +import pytest from django.test import TestCase from django.urls import reverse +from oauth2_provider.oauth2_validators import OAuth2Validator + +from . import presets + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestConnectDiscoveryInfoView(TestCase): def test_get_connect_discovery_info(self): expected_response = { - "issuer": "http://localhost", + "issuer": "http://localhost/o", "authorization_endpoint": "http://localhost/o/authorize/", "token_endpoint": "http://localhost/o/token/", - "userinfo_endpoint": "http://localhost/userinfo/", - "jwks_uri": "http://localhost/o/jwks/", + "userinfo_endpoint": "http://localhost/o/userinfo/", + "jwks_uri": "http://localhost/o/.well-known/jwks.json", + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + } + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + def test_get_connect_discovery_info_without_issuer_url(self): + self.oauth2_settings.OIDC_ISS_ENDPOINT = None + self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None + expected_response = { + "issuer": "http://testserver/o", + "authorization_endpoint": "http://testserver/o/authorize/", + "token_endpoint": "http://testserver/o/token/", + "userinfo_endpoint": "http://testserver/o/userinfo/", + "jwks_uri": "http://testserver/o/.well-known/jwks.json", "response_types_supported": [ "code", "token", @@ -19,29 +50,90 @@ def test_get_connect_discovery_info(self): "id_token token", "code token", "code id_token", - "code id_token token" + "code id_token token", ], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], - "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) assert response.json() == expected_response + def test_get_connect_discovery_info_without_rsa_key(self): + self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json()["id_token_signing_alg_values_supported"] == ["HS256"] + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestJwksInfoView(TestCase): def test_get_jwks_info(self): expected_response = { - "keys": [{ - "alg": "RS256", - "use": "sig", - "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", - "e": "AQAB", - "kty": "RSA", - "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8" # noqa - }] + "keys": [ + { + "alg": "RS256", + "use": "sig", + "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", + "e": "AQAB", + "kty": "RSA", + "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8", # noqa + } + ] } response = self.client.get(reverse("oauth2_provider:jwks-info")) self.assertEqual(response.status_code, 200) assert response.json() == expected_response + + def test_get_jwks_info_no_rsa_key(self): + self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + response = self.client.get(reverse("oauth2_provider:jwks-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == {"keys": []} + + +@pytest.mark.django_db +@pytest.mark.parametrize("method", ["get", "post"]) +def test_userinfo_endpoint(oidc_tokens, client, method): + auth_header = "Bearer %s" % oidc_tokens.access_token + rsp = getattr(client, method)( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_tokens.user.pk) + + +@pytest.mark.django_db +def test_userinfo_endpoint_bad_token(oidc_tokens, client): + # No access token + rsp = client.get(reverse("oauth2_provider:user-info")) + assert rsp.status_code == 401 + # Bad access token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION="Bearer not-a-real-token", + ) + assert rsp.status_code == 401 + + +@pytest.mark.django_db +def test_userinfo_endpoint_custom_claims(oidc_tokens, client, oauth2_settings): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self, request): + return {"state": "very nice"} + + oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_tokens.access_token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_tokens.user.pk) + assert "state" in data + assert data["state"] == "very nice" diff --git a/tests/test_password.py b/tests/test_password.py index f50404f..953b076 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,11 +1,11 @@ import json +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from oauth2_provider.models import get_application_model -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView from .utils import get_basic_auth_header @@ -21,6 +21,7 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -34,9 +35,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_PASSWORD, ) - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - def tearDown(self): self.application.delete() self.test_user.delete() @@ -60,8 +58,8 @@ def test_get_token(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(set(content["scope"].split()), {"read", "write"}) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_bad_credentials(self): """ diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 21a6ccd..a25611b 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -1,11 +1,13 @@ from datetime import timedelta -from django.conf.urls import include, url +import pytest +from django.conf.urls import include from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.test import TestCase from django.test.utils import override_settings +from django.urls import path, re_path from django.utils import timezone from rest_framework import permissions from rest_framework.authentication import BaseAuthentication @@ -13,18 +15,16 @@ from rest_framework.views import APIView from oauth2_provider.contrib.rest_framework import ( - IsAuthenticatedOrTokenHasScope, OAuth2Authentication, - TokenHasReadWriteScope, TokenHasResourceScope, - TokenHasScope, TokenMatchesOASRequirements + IsAuthenticatedOrTokenHasScope, + OAuth2Authentication, + TokenHasReadWriteScope, + TokenHasResourceScope, + TokenHasScope, + TokenMatchesOASRequirements, ) from oauth2_provider.models import get_access_token_model, get_application_model -from oauth2_provider.settings import oauth2_settings - -try: - from unittest import mock -except ImportError: - import mock +from . import presets Application = get_application_model() @@ -84,7 +84,10 @@ class MethodScopeAltViewBad(OAuth2View): class MissingAuthentication(BaseAuthentication): def authenticate(self, request): - return ("junk", "junk",) + return ( + "junk", + "junk", + ) class BrokenOAuth2View(MockView): @@ -109,25 +112,25 @@ class AuthenticationNoneOAuth2View(MockView): urlpatterns = [ - url(r"^oauth2/", include("oauth2_provider.urls")), - url(r"^oauth2-test/$", OAuth2View.as_view()), - url(r"^oauth2-scoped-test/$", ScopedView.as_view()), - url(r"^oauth2-scoped-missing-auth/$", TokenHasScopeViewWrongAuth.as_view()), - url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()), - url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()), - url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()), - url(r"^oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()), - url(r"^oauth2-method-scope-fail/$", MethodScopeAltViewBad.as_view()), - url(r"^oauth2-method-scope-missing-auth/$", MethodScopeAltViewWrongAuth.as_view()), - url(r"^oauth2-authentication-none/$", AuthenticationNoneOAuth2View.as_view()), + path("oauth2/", include("oauth2_provider.urls")), + path("oauth2-test/", OAuth2View.as_view()), + path("oauth2-scoped-test/", ScopedView.as_view()), + path("oauth2-scoped-missing-auth/", TokenHasScopeViewWrongAuth.as_view()), + path("oauth2-read-write-test/", ReadWriteScopedView.as_view()), + path("oauth2-resource-scoped-test/", ResourceScopedView.as_view()), + path("oauth2-authenticated-or-scoped-test/", AuthenticatedOrScopedView.as_view()), + re_path(r"oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()), + path("oauth2-method-scope-fail/", MethodScopeAltViewBad.as_view()), + path("oauth2-method-scope-missing-auth/", MethodScopeAltViewWrongAuth.as_view()), + path("oauth2-authentication-none/", AuthenticationNoneOAuth2View.as_view()), ] @override_settings(ROOT_URLCONF=__name__) +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.REST_FRAMEWORK_SCOPES) class TestOAuth2Authentication(TestCase): def setUp(self): - oauth2_settings._SCOPES = ["read", "write", "scope1", "scope2", "resource1"] - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") @@ -144,12 +147,9 @@ def setUp(self): scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", - application=self.application + application=self.application, ) - def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] - def _create_authorization_header(self, token): return "Bearer {0}".format(token) @@ -304,8 +304,8 @@ def test_resource_scoped_permission_post_denied(self): response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @mock.patch.object(oauth2_settings, "ERROR_RESPONSE_WITH_SCOPES", new=True) def test_required_scope_in_response(self): + self.oauth2_settings.ERROR_RESPONSE_WITH_SCOPES = True self.access_token.scope = "scope2" self.access_token.save() diff --git a/tests/test_scopes.py b/tests/test_scopes.py index f744d67..a310e22 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -1,18 +1,14 @@ import json from urllib.parse import parse_qs, urlparse +import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.urls import reverse -from oauth2_provider.models import ( - get_access_token_model, get_application_model, get_grant_model -) -from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views import ( - ReadWriteScopedResourceView, ScopedProtectedResourceView -) +from oauth2_provider.models import get_access_token_model, get_application_model, get_grant_model +from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView from .utils import get_basic_auth_header @@ -46,6 +42,19 @@ def post(self, request, *args, **kwargs): return "This is a write protected resource" +SCOPE_SETTINGS = { + "SCOPES": { + "read": "Read scope", + "write": "Write scope", + "scope1": "Custom scope 1", + "scope2": "Custom scope 2", + "scope3": "Custom scope 3", + }, +} + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(SCOPE_SETTINGS) class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -60,12 +69,7 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write", "scope1", "scope2", "scope3"] - oauth2_settings.READ_SCOPE = "read" - oauth2_settings.WRITE_SCOPE = "write" - def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] self.application.delete() self.test_user.delete() self.dev_user.delete() @@ -117,7 +121,7 @@ def test_scopes_save_in_access_token(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -153,7 +157,7 @@ def test_scopes_protection_valid(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -195,7 +199,7 @@ def test_scopes_protection_fail(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -237,7 +241,7 @@ def test_multi_scope_fail(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -279,7 +283,7 @@ def test_multi_scope_valid(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -320,7 +324,7 @@ def get_access_token(self, scopes): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -329,27 +333,27 @@ def get_access_token(self, scopes): return content["access_token"] def test_improperly_configured(self): - oauth2_settings.SCOPES = {"scope1": "Scope 1"} + self.oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} - oauth2_settings.READ_SCOPE = "ciccia" + self.oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} + self.oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) def test_properly_configured(self): - oauth2_settings.SCOPES = {"scope1": "Scope 1"} + self.oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} - oauth2_settings.READ_SCOPE = "ciccia" + self.oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} + self.oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) diff --git a/tests/test_scopes_backend.py b/tests/test_scopes_backend.py index 5f62961..925a4e3 100644 --- a/tests/test_scopes_backend.py +++ b/tests/test_scopes_backend.py @@ -3,9 +3,9 @@ def test_settings_scopes_get_available_scopes(): scopes = SettingsScopes() - assert scopes.get_available_scopes() == ["read", "write"] + assert set(scopes.get_available_scopes()) == {"read", "write"} def test_settings_scopes_get_default_scopes(): scopes = SettingsScopes() - assert scopes.get_default_scopes() == ["read", "write"] + assert set(scopes.get_default_scopes()) == {"read", "write"} diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..52bdafe --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,169 @@ +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.test import TestCase +from django.test.utils import override_settings +from oauthlib.common import Request + +from oauth2_provider.admin import ( + get_access_token_admin_class, + get_application_admin_class, + get_grant_admin_class, + get_id_token_admin_class, + get_refresh_token_admin_class, +) +from oauth2_provider.settings import OAuth2ProviderSettings, oauth2_settings, perform_import +from tests.admin import ( + CustomAccessTokenAdmin, + CustomApplicationAdmin, + CustomGrantAdmin, + CustomIDTokenAdmin, + CustomRefreshTokenAdmin, +) + +from . import presets + + +class TestAdminClass(TestCase): + def test_import_error_message_maintained(self): + """ + Make sure import errors are captured and raised sensibly. + """ + settings = OAuth2ProviderSettings({"CLIENT_ID_GENERATOR_CLASS": "invalid_module.InvalidClassName"}) + with self.assertRaises(ImportError): + settings.CLIENT_ID_GENERATOR_CLASS + + def test_get_application_admin_class(self): + """ + Test for getting class for application admin. + """ + application_admin_class = get_application_admin_class() + default_application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS + assert application_admin_class == default_application_admin_class + + def test_get_access_token_admin_class(self): + """ + Test for getting class for access token admin. + """ + access_token_admin_class = get_access_token_admin_class() + default_access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS + assert access_token_admin_class == default_access_token_admin_class + + def test_get_grant_admin_class(self): + """ + Test for getting class for grant admin. + """ + grant_admin_class = get_grant_admin_class() + default_grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS + assert grant_admin_class == default_grant_admin_class + + def test_get_id_token_admin_class(self): + """ + Test for getting class for ID token admin. + """ + id_token_admin_class = get_id_token_admin_class() + default_id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS + assert id_token_admin_class == default_id_token_admin_class + + def test_get_refresh_token_admin_class(self): + """ + Test for getting class for refresh token admin. + """ + refresh_token_admin_class = get_refresh_token_admin_class() + default_refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS + assert refresh_token_admin_class == default_refresh_token_admin_class + + @override_settings(OAUTH2_PROVIDER={"APPLICATION_ADMIN_CLASS": "tests.admin.CustomApplicationAdmin"}) + def test_get_custom_application_admin_class(self): + """ + Test for getting custom class for application admin. + """ + application_admin_class = get_application_admin_class() + assert application_admin_class == CustomApplicationAdmin + + @override_settings(OAUTH2_PROVIDER={"ACCESS_TOKEN_ADMIN_CLASS": "tests.admin.CustomAccessTokenAdmin"}) + def test_get_custom_access_token_admin_class(self): + """ + Test for getting custom class for access token admin. + """ + access_token_admin_class = get_access_token_admin_class() + assert access_token_admin_class == CustomAccessTokenAdmin + + @override_settings(OAUTH2_PROVIDER={"GRANT_ADMIN_CLASS": "tests.admin.CustomGrantAdmin"}) + def test_get_custom_grant_admin_class(self): + """ + Test for getting custom class for grant admin. + """ + grant_admin_class = get_grant_admin_class() + assert grant_admin_class == CustomGrantAdmin + + @override_settings(OAUTH2_PROVIDER={"ID_TOKEN_ADMIN_CLASS": "tests.admin.CustomIDTokenAdmin"}) + def test_get_custom_id_token_admin_class(self): + """ + Test for getting custom class for ID token admin. + """ + id_token_admin_class = get_id_token_admin_class() + assert id_token_admin_class == CustomIDTokenAdmin + + @override_settings(OAUTH2_PROVIDER={"REFRESH_TOKEN_ADMIN_CLASS": "tests.admin.CustomRefreshTokenAdmin"}) + def test_get_custom_refresh_token_admin_class(self): + """ + Test for getting custom class for refresh token admin. + """ + refresh_token_admin_class = get_refresh_token_admin_class() + assert refresh_token_admin_class == CustomRefreshTokenAdmin + + +def test_perform_import_when_none(): + assert perform_import(None, "REFRESH_TOKEN_ADMIN_CLASS") is None + + +def test_perform_import_list(): + imports = ["tests.admin.CustomIDTokenAdmin", "tests.admin.CustomGrantAdmin"] + assert perform_import(imports, "SOME_CLASSES") == [CustomIDTokenAdmin, CustomGrantAdmin] + + +def test_perform_import_already_imported(): + cls = perform_import(CustomRefreshTokenAdmin, "REFRESH_TOKEN_ADMIN_CLASS") + assert cls == CustomRefreshTokenAdmin + + +def test_invalid_scopes_raises_error(): + settings = OAuth2ProviderSettings( + { + "SCOPES": {"foo": "foo scope"}, + "DEFAULT_SCOPES": ["bar"], + } + ) + with pytest.raises(ImproperlyConfigured) as exc: + settings._DEFAULT_SCOPES + assert str(exc.value) == "Defined DEFAULT_SCOPES not present in SCOPES" + + +def test_missing_mandatory_setting_raises_error(): + settings = OAuth2ProviderSettings( + user_settings={}, defaults={"very_important": None}, mandatory=["very_important"] + ) + with pytest.raises(AttributeError) as exc: + settings.very_important + assert str(exc.value) == "OAuth2Provider setting: very_important is mandatory" + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +@pytest.mark.parametrize("issuer_setting", ["http://foo.com/", None]) +@pytest.mark.parametrize("request_type", ["django", "oauthlib"]) +def test_generating_iss_endpoint(oauth2_settings, issuer_setting, request_type, rf): + oauth2_settings.OIDC_ISS_ENDPOINT = issuer_setting + if request_type == "django": + request = rf.get("/") + elif request_type == "oauthlib": + request = Request("/", headers=rf.get("/").META) + expected = issuer_setting or "http://testserver/o" + assert oauth2_settings.oidc_issuer(request) == expected + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_generating_iss_endpoint_type_error(oauth2_settings): + oauth2_settings.OIDC_ISS_ENDPOINT = None + with pytest.raises(TypeError) as exc: + oauth2_settings.oidc_issuer(None) + assert str(exc.value) == "request must be a django or oauthlib request: got None" diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index fdbc072..1ed1c91 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -5,10 +5,7 @@ from django.urls import reverse from django.utils import timezone -from oauth2_provider.models import ( - get_access_token_model, get_application_model, get_refresh_token_model -) -from oauth2_provider.settings import oauth2_settings +from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model Application = get_application_model() @@ -31,8 +28,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write"] - def tearDown(self): self.application.delete() self.test_user.delete() @@ -41,15 +36,14 @@ def tearDown(self): class TestRevocationView(BaseTest): def test_revoke_access_token(self): - """ - - """ tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, @@ -72,9 +66,11 @@ def test_revoke_access_token_public(self): public_app.save() tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", application=public_app, + user=self.test_user, + token="1234567890", + application=public_app, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) data = { @@ -87,21 +83,21 @@ def test_revoke_access_token_public(self): self.assertEqual(response.status_code, 200) def test_revoke_access_token_with_hint(self): - """ - - """ tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, - "token_type_hint": "access_token" + "token_type_hint": "access_token", } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -109,18 +105,21 @@ def test_revoke_access_token_with_hint(self): def test_revoke_access_token_with_invalid_hint(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) # invalid hint should have no effect + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, - "token_type_hint": "bad_hint" + "token_type_hint": "bad_hint", } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -128,20 +127,22 @@ def test_revoke_access_token_with_invalid_hint(self): def test_revoke_refresh_token(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) rtok = RefreshToken.objects.create( - user=self.test_user, token="999999999", - application=self.application, access_token=tok + user=self.test_user, token="999999999", application=self.application, access_token=tok ) + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": rtok.token, } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -151,14 +152,14 @@ def test_revoke_refresh_token(self): def test_revoke_refresh_token_with_revoked_access_token(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) rtok = RefreshToken.objects.create( - user=self.test_user, token="999999999", - application=self.application, access_token=tok + user=self.test_user, token="999999999", application=self.application, access_token=tok ) for token in (tok.token, rtok.token): data = { @@ -166,6 +167,7 @@ def test_revoke_refresh_token_with_revoked_access_token(self): "client_secret": self.application.client_secret, "token": token, } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -183,18 +185,20 @@ def test_revoke_token_with_wrong_hint(self): .. _`Section 4.1.2`: http://tools.ietf.org/html/draft-ietf-oauth-revocation-11#section-4.1.2 """ tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, - "token_type_hint": "refresh_token" + "token_type_hint": "refresh_token", } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) diff --git a/tests/test_token_view.py b/tests/test_token_view.py index fc3044c..784ea3b 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -17,6 +17,7 @@ class TestAuthorizedTokenViews(TestCase): """ TestCase superclass for Authorized Token Views" Test Cases """ + def setUp(self): self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") @@ -38,6 +39,7 @@ class TestAuthorizedTokenListView(TestAuthorizedTokenViews): """ Tests for the Authorized Token ListView """ + def test_list_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. @@ -62,10 +64,11 @@ def test_list_view_one_token(self): """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( - user=self.bar_user, token="1234567890", + user=self.bar_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) @@ -80,16 +83,18 @@ def test_list_view_two_tokens(self): """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( - user=self.bar_user, token="1234567890", + user=self.bar_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) AccessToken.objects.create( - user=self.bar_user, token="0123456789", + user=self.bar_user, + token="0123456789", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) @@ -102,10 +107,11 @@ def test_list_view_shows_correct_user_token(self): """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) @@ -117,15 +123,17 @@ class TestAuthorizedTokenDeleteView(TestAuthorizedTokenViews): """ Tests for the Authorized Token DeleteView """ + def test_delete_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) @@ -138,10 +146,11 @@ def test_delete_view_works(self): Test that a GET on this view returns 200 if the token belongs to the logged-in user. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="foo_user", password="123456") @@ -154,10 +163,11 @@ def test_delete_view_token_belongs_to_user(self): Test that a 404 is returned when trying to GET this view with someone else"s tokens. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="bar_user", password="123456") @@ -170,10 +180,11 @@ def test_delete_view_post_actually_deletes(self): Test that a POST on this view works if the token belongs to the logged-in user. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="foo_user", password="123456") @@ -187,10 +198,11 @@ def test_delete_view_only_deletes_user_own_token(self): Test that a 404 is returned when trying to POST on this view with someone else"s tokens. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="bar_user", password="123456") diff --git a/tests/test_validators.py b/tests/test_validators.py index 82930a9..0760e02 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,10 +1,11 @@ +import pytest from django.core.validators import ValidationError from django.test import TestCase -from oauth2_provider.settings import oauth2_settings from oauth2_provider.validators import RedirectURIValidator +@pytest.mark.usefixtures("oauth2_settings") class TestValidators(TestCase): def test_validate_good_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) @@ -37,7 +38,7 @@ def test_validate_custom_uri_scheme(self): def test_validate_bad_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] bad_uris = [ "http:/example.com", "HTTP://localhost", diff --git a/tests/urls.py b/tests/urls.py index 16dcf6d..0661a93 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,13 +1,11 @@ -from django.conf.urls import include, url from django.contrib import admin +from django.urls import include, path admin.autodiscover() urlpatterns = [ - url(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path("admin/", admin.site.urls), ] - - -urlpatterns += [url(r"^admin/", admin.site.urls)] diff --git a/tests/utils.py b/tests/utils.py index ec25905..b7dc200 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,4 +1,5 @@ import base64 +from unittest import mock def get_basic_auth_header(user, password): @@ -13,3 +14,19 @@ def get_basic_auth_header(user, password): } return auth_headers + + +def spy_on(meth): + """ + Util function to add a spy onto a method of a class. + """ + spy = mock.MagicMock() + + def wrapper(self, *args, **kwargs): + spy(self, *args, **kwargs) + return_value = meth(self, *args, **kwargs) + spy.returned = return_value + return return_value + + wrapper.spy = spy + return wrapper diff --git a/tox.ini b/tox.ini index 7d2de23..8371aab 100644 --- a/tox.ini +++ b/tox.ini @@ -1,67 +1,89 @@ [tox] envlist = - py37-flake8, - py37-docs, - py38-django{30,22,21}, - py37-django{30,22,21}, - py36-django{22,21}, - py35-django{22,21}, - py38-djangomaster, - py37-djangomaster, - py36-djangomaster, + flake8, + docs, + py{36,37,38,39}-dj{32,31,22}, + py{38,39}-djmain, + +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38, docs, flake8 + 3.9: py39 [pytest] django_find_project = false +addopts = + --cov=oauth2_provider + --cov-report= + --cov-append + -s +markers = + oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture [testenv] commands = - pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} -s + pytest {posargs} + coverage report + coverage xml setenv = - DJANGO_SETTINGS_MODULE = tests.settings - PYTHONPATH = {toxinidir} - PYTHONWARNINGS = all + DJANGO_SETTINGS_MODULE = tests.settings + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = all deps = - django21: Django>=2.1,<2.2 - django22: Django>=2.2,<3 - django30: Django>=3.0,<3.1 - djangomaster: https://github.com/django/django/archive/master.tar.gz - djangorestframework - oauthlib>=3.0.1 - coverage - pytest - pytest-cov - pytest-django - pytest-xdist - py27: mock - requests - jwcrypto + dj22: Django>=2.2,<3 + dj31: Django>=3.1,<3.2 + dj32: Django>=3.2,<3.3 + djmain: https://github.com/django/django/archive/main.tar.gz + djangorestframework + oauthlib>=3.1.0 + jwcrypto + coverage + pytest + pytest-cov + pytest-django + pytest-xdist + pytest-mock + requests +passenv = + PYTEST_ADDOPTS + +[testenv:py{38,39}-djmain] +ignore_errors = true +ignore_outcome = true -[testenv:py37-docs] -basepython = python +[testenv:{docs,livedocs}] +basepython = python3.8 changedir = docs whitelist_externals = make -commands = make html -deps = sphinx<3 - oauthlib>=3.0.1 - m2r>=0.2.1 - jwcrypto +commands = + docs: make html + livedocs: make livehtml +deps = + sphinx<3 + oauthlib>=3.1.0 + m2r>=0.2.1 + sphinx-rtd-theme + livedocs: sphinx-autobuild + jwcrypto -[testenv:py37-flake8] +[testenv:flake8] +basepython = python3.8 skip_install = True -commands = - flake8 {toxinidir} +commands = flake8 {toxinidir} deps = - flake8 - flake8-isort - flake8-quotes + flake8 + flake8-isort + flake8-quotes + flake8-black [testenv:install] deps = twine setuptools>=39.0 wheel -whitelist_externals= - rm +whitelist_externals = rm commands = rm -rf dist python setup.py sdist bdist_wheel @@ -70,21 +92,26 @@ commands = [coverage:run] source = oauth2_provider -omit = - */migrations/* - oauth2_provider/settings.py +omit = */migrations/* + +[coverage:report] +show_missing = True [flake8] max-line-length = 110 exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/ application-import-names = oauth2_provider inline-quotes = double +extend-ignore = E203, W503 [isort] -balanced_wrapping = True default_section = THIRDPARTY known_first_party = oauth2_provider -line_length = 80 +line_length = 110 lines_after_imports = 2 -multi_line_output = 5 -skip = oauth2_provider/migrations/, .tox/ +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +skip = oauth2_provider/migrations/, .tox/, tests/migrations/