├── .dockerignore ├── .editorconfig ├── .env ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── pull_request_template.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── .vscode └── settings.json ├── AUTHORS ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docker-compose.yml ├── docs ├── Makefile ├── _images │ ├── application-authorize-web-app.png │ ├── application-register-auth-code.png │ └── application-register-client-credential.png ├── advanced_topics.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── getting_started.rst ├── glossary.rst ├── index.rst ├── install.rst ├── management_commands.rst ├── models.rst ├── oidc.rst ├── requirements.txt ├── resource_server.rst ├── rest-framework │ ├── getting_started.rst │ ├── openapi.yaml │ ├── permissions.rst │ └── rest-framework.rst ├── rfc.py ├── settings.rst ├── signals.rst ├── templates.rst ├── tutorial │ ├── admin+celery.png │ ├── celery+add.png │ ├── tutorial.rst │ ├── tutorial_01.rst │ ├── tutorial_02.rst │ ├── tutorial_03.rst │ ├── tutorial_04.rst │ └── tutorial_05.rst └── views │ ├── application.rst │ ├── class_based.rst │ ├── details.rst │ ├── function_based.rst │ ├── mixins.rst │ ├── token.rst │ └── views.rst ├── oauth2_provider ├── __init__.py ├── admin.py ├── apps.py ├── backends.py ├── checks.py ├── compat.py ├── contrib │ ├── __init__.py │ └── rest_framework │ │ ├── __init__.py │ │ ├── authentication.py │ │ └── permissions.py ├── decorators.py ├── exceptions.py ├── forms.py ├── generators.py ├── http.py ├── locale │ ├── es │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── fa │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── ja │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── pt │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── pt_BR │ │ └── LC_MESSAGES │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── cleartokens.py │ │ └── createapplication.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190406_1805.py │ ├── 0003_auto_20201211_1314.py │ ├── 0004_auto_20200902_2022.py │ ├── 0005_auto_20211222_2352.py │ ├── 0006_alter_application_client_secret.py │ ├── 0007_application_post_logout_redirect_uris.py │ ├── 0008_alter_accesstoken_token.py │ ├── 0009_add_hash_client_secret.py │ ├── 0010_application_allowed_origins.py │ ├── 0011_refreshtoken_token_family.py │ ├── 0012_add_token_checksum.py │ └── __init__.py ├── models.py ├── oauth2_backends.py ├── oauth2_validators.py ├── scopes.py ├── settings.py ├── signals.py ├── templates │ └── oauth2_provider │ │ ├── application_confirm_delete.html │ │ ├── application_detail.html │ │ ├── application_form.html │ │ ├── application_list.html │ │ ├── application_registration_form.html │ │ ├── authorize.html │ │ ├── authorized-token-delete.html │ │ ├── authorized-tokens.html │ │ ├── base.html │ │ └── logout_confirm.html ├── urls.py ├── utils.py ├── validators.py └── views │ ├── __init__.py │ ├── application.py │ ├── base.py │ ├── generic.py │ ├── introspect.py │ ├── mixins.py │ ├── oidc.py │ └── token.py ├── pyproject.toml ├── tests ├── __init__.py ├── admin.py ├── app │ ├── README.md │ ├── idp │ │ ├── README.md │ │ ├── fixtures │ │ │ └── seed.json │ │ ├── idp │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ ├── asgi.py │ │ │ ├── oauth.py │ │ │ ├── settings.py │ │ │ ├── urls.py │ │ │ └── wsgi.py │ │ ├── manage.py │ │ ├── requirements.txt │ │ └── templates │ │ │ └── registration │ │ │ └── login.html │ └── rp │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .prettierignore │ │ ├── .prettierrc │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ ├── app.d.ts │ │ ├── app.html │ │ └── routes │ │ │ └── +page.svelte │ │ ├── static │ │ └── favicon.png │ │ ├── svelte.config.js │ │ ├── tsconfig.jsonc │ │ └── vite.config.ts ├── common_testing.py ├── conftest.py ├── custom_hasher.py ├── db_router.py ├── mig_settings.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_swapped_models.py │ ├── 0003_basetestapplication_post_logout_redirect_uris_and_more.py │ ├── 0004_basetestapplication_hash_client_secret_and_more.py │ ├── 0005_basetestapplication_allowed_origins_and_more.py │ ├── 0006_basetestapplication_token_family.py │ ├── 0007_add_localidtoken.py │ └── __init__.py ├── models.py ├── multi_db_settings.py ├── multi_db_settings_invalid_token_configuration.py ├── presets.py ├── settings.py ├── settings_swapped.py ├── test_application_views.py ├── test_auth_backends.py ├── test_authorization_code.py ├── test_client_credential.py ├── test_commands.py ├── test_decorators.py ├── test_django_checks.py ├── test_generator.py ├── test_hybrid.py ├── test_implicit.py ├── test_introspection_auth.py ├── test_introspection_view.py ├── test_mixins.py ├── test_models.py ├── test_oauth2_backends.py ├── test_oauth2_validators.py ├── test_oidc_views.py ├── test_password.py ├── test_rest_framework.py ├── test_scopes.py ├── test_scopes_backend.py ├── test_settings.py ├── test_token_endpoint_cors.py ├── test_token_revocation.py ├── test_token_view.py ├── test_ui_locales.py ├── test_utils.py ├── test_validators.py ├── urls.py └── utils.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | venv 2 | __pycache__ 3 | .tox 4 | .github 5 | .vscode 6 | .django_oauth_toolkit.egg-info 7 | .coverage 8 | coverage.xml 9 | 10 | # every time we change this we need to do the COPY . /code and 11 | # RUN pip install -r requirements.txt again 12 | # so don't include the Dockerfile in the context. 13 | Dockerfile 14 | docker-compose.yml 15 | 16 | 17 | # from .gitignore 18 | *.py[cod] 19 | 20 | *.swp 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Packages 26 | *.egg 27 | *.egg-info 28 | dist 29 | build 30 | eggs 31 | parts 32 | bin 33 | var 34 | sdist 35 | develop-eggs 36 | .installed.cfg 37 | lib 38 | lib64 39 | __pycache__ 40 | 41 | # Installer logs 42 | pip-log.txt 43 | 44 | # Unit test / coverage reports 45 | .cache 46 | .pytest_cache 47 | .coverage 48 | .tox 49 | .pytest_cache/ 50 | nosetests.xml 51 | 52 | # Translations 53 | *.mo 54 | 55 | # Mr Developer 56 | .mr.developer.cfg 57 | .project 58 | .pydevproject 59 | 60 | # PyCharm stuff 61 | .idea 62 | 63 | # Sphinx build dir 64 | _build 65 | 66 | # Sqlite database files 67 | *.sqlite 68 | 69 | /venv/ 70 | /coverage.xml 71 | 72 | db.sqlite3 73 | venv/ 74 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{Makefile,setup.cfg}] 12 | indent_style = tab 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # required for vscode testing activity to discover tests 2 | DJANGO_SETTINGS_MODULE=tests.settings -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | **Expected behavior** 17 | 18 | 19 | **Version** 20 | 21 | 22 | 23 | 24 | - [ ] I have tested with the latest published release and it's still a problem. 25 | - [ ] I have tested with the master branch and it's still a problem. 26 | 27 | **Additional context** 28 | 29 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Describe alternatives you've considered** 17 | 18 | 19 | **Additional context** 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about using django-oauth-toolkit 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fixes # 4 | 5 | ## Description of the Change 6 | 7 | ## Checklist 8 | 9 | 10 | 11 | 12 | - [ ] PR only contains one change (considered splitting up PR) 13 | - [ ] unit-test added 14 | - [ ] documentation updated 15 | - [ ] `CHANGELOG.md` updated (only for user relevant changes) 16 | - [ ] author name in `AUTHORS` 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-oauth-toolkit' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.12' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip build twine 26 | 27 | - name: Build package 28 | run: | 29 | python -m build 30 | twine check dist/* 31 | 32 | - name: Upload packages to Jazzband 33 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 34 | uses: pypa/gh-action-pypi-publish@master 35 | with: 36 | user: jazzband 37 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 38 | repository_url: https://jazzband.co/projects/django-oauth-toolkit/upload 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test-package: 7 | name: Test Package (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: 13 | - '3.10' 14 | - '3.11' 15 | - '3.12' 16 | django-version: 17 | - '4.2' 18 | - '5.0' 19 | - '5.1' 20 | - 'main' 21 | include: 22 | # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django 23 | - python-version: '3.8' 24 | django-version: '4.2' 25 | - python-version: '3.9' 26 | django-version: '4.2' 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | 37 | - name: Get pip cache dir 38 | id: pip-cache 39 | run: | 40 | echo "::set-output name=dir::$(pip cache dir)" 41 | 42 | - name: Cache 43 | uses: actions/cache@v4 44 | with: 45 | path: ${{ steps.pip-cache.outputs.dir }} 46 | key: 47 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} 48 | restore-keys: | 49 | ${{ matrix.python-version }}-v1- 50 | 51 | - name: Install Python dependencies 52 | run: | 53 | python -m pip install --upgrade pip 54 | python -m pip install --upgrade tox tox-gh-actions 55 | 56 | - name: Tox tests 57 | run: | 58 | tox -v 59 | env: 60 | DJANGO: ${{ matrix.django-version }} 61 | 62 | - name: Upload coverage 63 | uses: codecov/codecov-action@v3 64 | with: 65 | name: Python ${{ matrix.python-version }} 66 | 67 | test-demo-rp: 68 | name: Test Demo Relying Party 69 | runs-on: ubuntu-latest 70 | strategy: 71 | fail-fast: false 72 | matrix: 73 | node-version: 74 | - "18.x" 75 | - "20.x" 76 | steps: 77 | - name: Checkout 78 | uses: actions/checkout@v4 79 | 80 | - name: Set up NodeJS 81 | uses: actions/setup-node@v4 82 | with: 83 | node-version: ${{ matrix.node-version }} 84 | 85 | - name: Install dependencies 86 | run: npm install 87 | working-directory: tests/app/rp 88 | 89 | - name: Run Lint 90 | run: npm run lint 91 | working-directory: tests/app/rp 92 | 93 | - name: Run build 94 | run: npm run build 95 | working-directory: tests/app/rp 96 | 97 | success: 98 | needs: 99 | - test-package 100 | - test-demo-rp 101 | runs-on: ubuntu-latest 102 | name: Test successful 103 | steps: 104 | - name: Success 105 | run: echo Test successful 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | *.swp 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | dist 12 | build 13 | eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | __pycache__ 23 | 24 | # Installer logs 25 | pip-log.txt 26 | 27 | # Unit test / coverage reports 28 | .cache 29 | .pytest_cache 30 | .coverage 31 | .tox 32 | .pytest_cache/ 33 | .ruff_cache/ 34 | nosetests.xml 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | 44 | # PyCharm stuff 45 | .idea 46 | 47 | # Sphinx build dir 48 | _build 49 | 50 | # Sqlite database files 51 | *.sqlite 52 | 53 | /venv/ 54 | /coverage.xml 55 | 56 | db.sqlite3 57 | venv/ 58 | 59 | /tests/app/idp/static 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.12 4 | hooks: 5 | - id: ruff 6 | args: [ --fix ] 7 | - id: ruff-format 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: check-ast 12 | - id: trailing-whitespace 13 | - id: check-merge-conflict 14 | - id: check-json 15 | - id: check-xml 16 | - id: check-yaml 17 | - id: mixed-line-ending 18 | args: ['--fix=lf'] 19 | - repo: https://github.com/sphinx-contrib/sphinx-lint 20 | rev: v1.0.0 21 | hooks: 22 | - id: sphinx-lint 23 | # Configuration for codespell is in pyproject.toml 24 | - repo: https://github.com/codespell-project/codespell 25 | rev: v2.4.1 26 | hooks: 27 | - id: codespell 28 | exclude: (package-lock.json|/locale/) 29 | additional_dependencies: 30 | - tomli 31 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-20.04 11 | tools: 12 | python: "3.9" 13 | # You can also specify other tool versions: 14 | # nodejs: "16" 15 | # rust: "1.55" 16 | # golang: "1.17" 17 | 18 | # Build documentation in the docs/ directory with Sphinx 19 | sphinx: 20 | configuration: docs/conf.py 21 | 22 | # If using Sphinx, optionally build your docs in additional formats such as PDF 23 | # formats: 24 | # - pdf 25 | 26 | # Optionally declare the Python requirements required to build your docs 27 | python: 28 | install: 29 | - requirements: docs/requirements.txt 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests", 4 | "--no-cov" 5 | ], 6 | "python.testing.unittestEnabled": false, 7 | "python.testing.pytestEnabled": true 8 | } -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors 2 | ------- 3 | 4 | Massimiliano Pippi 5 | Federico Frenguelli 6 | 7 | Contributors 8 | ------------ 9 | 10 | Abhishek Patel 11 | Adam Johnson 12 | Adam Zahradník 13 | Adheeth P Praveen 14 | Alan Crosswell 15 | Alan Rominger 16 | Alejandro Mantecon Guillen 17 | Aleksander Vaskevich 18 | Alessandro De Angelis 19 | Alex Manning 20 | Alex Szabó 21 | Aliaksei Kanstantsinau 22 | Allisson Azevedo 23 | Andrea Greco 24 | Andrej Zbín 25 | Andrew Chen Wang 26 | Andrew Zickler 27 | Antoine Laurent 28 | Anvesh Agarwal 29 | Aristóbulo Meneses 30 | Aryan Iyappan 31 | Asaf Klibansky 32 | Ash Christopher 33 | Asif Saif Uddin 34 | Bart Merenda 35 | Bas van Oostveen 36 | Brian Helba 37 | Carl Schwan 38 | Daniel Golding 39 | Daniel 'Vector' Kerr 40 | Darrel O'Pry 41 | Dave Burkholder 42 | David Fischer 43 | David Hill 44 | David Smith 45 | Dawid Wolski 46 | Diego Garcia 47 | Dominik George 48 | Dulmandakh Sukhbaatar 49 | Dylan Giesler 50 | Dylan Tack 51 | Eduardo Oliveira 52 | Egor Poderiagin 53 | Emanuele Palazzetti 54 | Fazeel Ghafoor 55 | Federico Dolce 56 | Florian Demmer 57 | Frederico Vieira 58 | Gaël Utard 59 | Glauco Junior 60 | Giovanni Giampauli 61 | Hasan Ramezani 62 | Hiroki Kiyohara 63 | Hossein Shakiba 64 | Islam Kamel 65 | Ivan Lukyanets 66 | Jaap Roes 67 | Jadiel Teófilo 68 | Jens Timmerman 69 | Jerome Leclanche 70 | Jesse Gibbs 71 | Jim Graham 72 | John Byrne 73 | Jonas Nygaard Pedersen 74 | Jonathan Steffan 75 | Jordi Sanchez 76 | Joseph Abrahams 77 | Josh Thomas 78 | Jozef Knaperek 79 | Julian Mundhahs 80 | Julien Palard 81 | Jun Zhou 82 | Kaleb Porter 83 | Kristian Rune Larsen 84 | Lazaros Toumanidis 85 | Ludwig Hähne 86 | Łukasz Skarżyński 87 | Madison Swain-Bowden 88 | Marcus Sonestedt 89 | Matej Spiller Muys 90 | Matias Seniquiel 91 | Michael Howitz 92 | Owen Gong 93 | Patrick Palacin 94 | Paul Dekkers 95 | Paul Oswald 96 | Pavel Tvrdík 97 | Peter Carnesciali 98 | Peter Karman 99 | Peter McDonald 100 | Petr Dlouhý 101 | pySilver 102 | Rodney Richardson 103 | Rustem Saiargaliev 104 | Rustem Saiargaliev 105 | Sandro Rodrigues 106 | Sean 'Shaleh' Perry 107 | Shaheed Haque 108 | Shaun Stanworth 109 | Sayyid Hamid Mahdavi 110 | Silvano Cerza 111 | Sora Yanai 112 | Sören Wegener 113 | Spencer Carroll 114 | Stéphane Raimbault 115 | Tom Evans 116 | Vinay Karanam 117 | Víðir Valberg Guðmundsson 118 | Will Beaufoy 119 | pySilver 120 | Łukasz Skarżyński 121 | Wouter Klein Heerenbrink 122 | Yaroslav Halchenko 123 | Yuri Savin 124 | Miriam Forner 125 | Alex Kerkum 126 | q0w 127 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 4 | 5 | # Contribute to Django OAuth Toolkit 6 | 7 | Thanks for your interest, we love contributions! 8 | 9 | Please [follow these guidelines](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) 10 | when submitting pull requests. 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.6.0 2 | # this Dockerfile is located at the root so the build context 3 | # includes oauth2_provider which is a requirement of the 4 | # tests/app/idp. This way we build images with the source 5 | # code from the repos for validation before publishing packages. 6 | 7 | FROM python:3.11.6-slim as builder 8 | 9 | ENV PYTHONDONTWRITEBYTECODE 1 10 | ENV PYTHONUNBUFFERED 1 11 | 12 | ENV DEBUG=False 13 | ENV ALLOWED_HOSTS="*" 14 | ENV TEMPLATES_DIRS="/data/templates" 15 | ENV STATIC_ROOT="/data/static" 16 | ENV DATABASE_URL="sqlite:////data/db.sqlite3" 17 | 18 | RUN apt-get update 19 | # Build Deps 20 | RUN apt-get install -y --no-install-recommends gcc libc-dev python3-dev git openssh-client libpq-dev file libev-dev 21 | # bundle code in a virtual env to make copying to the final image without all the upstream stuff easier. 22 | RUN python -m venv /opt/venv 23 | ENV PATH="/opt/venv/bin:$PATH" 24 | # need to update pip and setuptools for pep517 support required by gevent. 25 | RUN pip install --upgrade pip 26 | RUN pip install --upgrade setuptools 27 | COPY . /code 28 | WORKDIR /code/tests/app/idp 29 | RUN pip install -r requirements.txt 30 | RUN pip install gunicorn 31 | RUN python manage.py collectstatic --noinput 32 | 33 | 34 | 35 | FROM python:3.11.6-slim 36 | 37 | # allow embed sha1 at build time as release. 38 | ARG GIT_SHA1 39 | 40 | LABEL org.opencontainers.image.authors="https://jazzband.co/projects/django-oauth-toolkit" 41 | LABEL org.opencontainers.image.source="https://github.com/jazzband/django-oauth-toolkit" 42 | LABEL org.opencontainers.image.revision=${GIT_SHA1} 43 | 44 | 45 | ENV SENTRY_RELEASE=${GIT_SHA1} 46 | 47 | # disable debug mode, but allow all hosts by default when running in docker 48 | ENV DEBUG=False 49 | ENV ALLOWED_HOSTS="*" 50 | ENV TEMPLATES_DIRS="/data/templates" 51 | ENV STATIC_ROOT="/data/static" 52 | ENV DATABASE_URL="sqlite:////data/db.sqlite3" 53 | 54 | 55 | 56 | 57 | COPY --from=builder /opt/venv /opt/venv 58 | ENV PATH="/opt/venv/bin:$PATH" 59 | COPY --from=builder /code /code 60 | RUN mkdir -p /data/static /data/templates 61 | COPY --from=builder /code/tests/app/idp/static /data/static 62 | COPY --from=builder /code/tests/app/idp/templates /data/templates 63 | 64 | WORKDIR /code/tests/app/idp 65 | RUN apt-get update && apt-get install -y \ 66 | libpq5 \ 67 | && rm -rf /var/lib/apt/lists/* 68 | EXPOSE 80 69 | VOLUME ["/data" ] 70 | CMD ["gunicorn", "idp.wsgi:application", "-w 4 -b 0.0.0.0:80 --chdir=/code --worker-tmp-dir /dev/shm --timeout 120 --error-logfile '-' --log-level debug --access-logfile '-'"] 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Massimiliano Pippi, Federico Frenguelli and contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | The views and conclusions contained in the software and documentation are those 25 | of the authors and should not be interpreted as representing official policies, 26 | either expressed or implied, of the FreeBSD Project. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | recursive-include oauth2_provider/templates *.html 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django OAuth Toolkit 2 | ==================== 3 | 4 | .. image:: https://jazzband.co/static/img/badge.svg 5 | :target: https://jazzband.co/ 6 | :alt: Jazzband 7 | 8 | *OAuth2 goodies for the Djangonauts!* 9 | 10 | .. image:: https://badge.fury.io/py/django-oauth-toolkit.svg 11 | :target: http://badge.fury.io/py/django-oauth-toolkit 12 | 13 | .. image:: https://github.com/jazzband/django-oauth-toolkit/workflows/Test/badge.svg 14 | :target: https://github.com/jazzband/django-oauth-toolkit/actions 15 | :alt: GitHub Actions 16 | 17 | .. image:: https://codecov.io/gh/jazzband/django-oauth-toolkit/branch/master/graph/badge.svg 18 | :target: https://codecov.io/gh/jazzband/django-oauth-toolkit 19 | :alt: Coverage 20 | 21 | .. image:: https://img.shields.io/pypi/pyversions/django-oauth-toolkit.svg 22 | :target: https://pypi.org/project/django-oauth-toolkit/ 23 | :alt: Supported Python versions 24 | 25 | .. image:: https://img.shields.io/pypi/djversions/django-oauth-toolkit.svg 26 | :target: https://pypi.org/project/django-oauth-toolkit/ 27 | :alt: Supported Django versions 28 | 29 | If you are facing one or more of the following: 30 | * Your Django app exposes a web API you want to protect with OAuth2 authentication, 31 | * You need to implement an OAuth2 authorization server to provide tokens management for your infrastructure, 32 | 33 | Django OAuth Toolkit can help you providing out of the box all the endpoints, data and logic needed to add OAuth2 34 | capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent 35 | `OAuthLib `_, so that everything is 36 | `rfc-compliant `_. 37 | 38 | Reporting security issues 39 | ------------------------- 40 | 41 | Please report any security issues to the JazzBand security team at . Do not file an issue on the tracker. 42 | 43 | Requirements 44 | ------------ 45 | 46 | * Python 3.8+ 47 | * Django 4.2, 5.0 or 5.1 48 | * oauthlib 3.2.2+ 49 | 50 | Installation 51 | ------------ 52 | 53 | Install with pip:: 54 | 55 | pip install django-oauth-toolkit 56 | 57 | Add ``oauth2_provider`` to your ``INSTALLED_APPS`` 58 | 59 | .. code-block:: python 60 | 61 | INSTALLED_APPS = ( 62 | ... 63 | 'oauth2_provider', 64 | ) 65 | 66 | 67 | If you need an OAuth2 provider you'll want to add the following to your ``urls.py``. 68 | 69 | .. code-block:: python 70 | 71 | from oauth2_provider import urls as oauth2_urls 72 | 73 | urlpatterns = [ 74 | ... 75 | path('o/', include(oauth2_urls)), 76 | ] 77 | 78 | Changelog 79 | --------- 80 | 81 | See `CHANGELOG.md `_. 82 | 83 | 84 | Documentation 85 | -------------- 86 | 87 | The `full documentation `_ is on *Read the Docs*. 88 | 89 | License 90 | ------- 91 | 92 | django-oauth-toolkit is released under the terms of the **BSD license**. Full details in ``LICENSE`` file. 93 | 94 | Help Wanted 95 | ----------- 96 | 97 | We need help maintaining and enhancing django-oauth-toolkit (DOT). 98 | 99 | Join the team 100 | ~~~~~~~~~~~~~ 101 | 102 | Please consider joining `Jazzband `__ (If not 103 | already a member) and the `DOT project 104 | team `__. 105 | 106 | How you can help 107 | ~~~~~~~~~~~~~~~~ 108 | 109 | See our 110 | `contributing `__ 111 | info and the open 112 | `issues `__ and 113 | `PRs `__, 114 | especially those labeled 115 | `help-wanted `__. 116 | 117 | Discussions 118 | ~~~~~~~~~~~ 119 | Have questions or want to discuss the project? 120 | See `the discussions `__. 121 | 122 | 123 | Submit PRs and Perform Reviews 124 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 125 | 126 | PR submissions and reviews are always appreciated! Since we require an 127 | independent review of any PR before it can be merged, having your second 128 | set of eyes looking at PRs is extremely valuable. 129 | 130 | Please don’t merge PRs 131 | ~~~~~~~~~~~~~~~~~~~~~~ 132 | 133 | Please be aware that we don’t want *every* Jazzband member to merge PRs 134 | but just a handful of project team members so that we can maintain a 135 | modicum of control over what goes into a release of this security oriented code base. Only `project 136 | leads `__ are able to 137 | publish releases to Pypi and it becomes difficult when creating a new 138 | release for the leads to deal with “unexpected” merged PRs. 139 | 140 | Become a Project Lead 141 | ~~~~~~~~~~~~~~~~~~~~~ 142 | 143 | If you are interested in stepping up to be a Project Lead, please take a look at 144 | the `discussion about this `__. 145 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | idp-data: 3 | 4 | 5 | x-idp: &idp 6 | image: django-oauth-toolkit/idp 7 | volumes: 8 | - idp-data:/data 9 | 10 | services: 11 | idp-migrate: 12 | <<: *idp 13 | build: . 14 | command: python manage.py migrate 15 | 16 | idp-loaddata: 17 | <<: *idp 18 | command: python manage.py loaddata fixtures/seed.json 19 | depends_on: 20 | idp-migrate: 21 | condition: service_completed_successfully 22 | 23 | idp: 24 | <<: *idp 25 | command: gunicorn idp.wsgi:application -w 4 -b 0.0.0.0:80 --chdir=/code --timeout 120 --error-logfile '-' --log-level debug --access-logfile '-' 26 | ports: 27 | # map to dev port. 28 | - "8000:80" 29 | depends_on: 30 | idp-loaddata: 31 | condition: service_completed_successfully 32 | 33 | rp: 34 | image: django-oauth-toolkit/rp 35 | build: ./tests/app/rp 36 | ports: 37 | # map to dev port. 38 | - "5173:3000" 39 | depends_on: 40 | - idp -------------------------------------------------------------------------------- /docs/_images/application-authorize-web-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/docs/_images/application-authorize-web-app.png -------------------------------------------------------------------------------- /docs/_images/application-register-auth-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/docs/_images/application-register-auth-code.png -------------------------------------------------------------------------------- /docs/_images/application-register-client-credential.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/docs/_images/application-register-client-credential.png -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../CHANGELOG.md 2 | -------------------------------------------------------------------------------- /docs/glossary.rst: -------------------------------------------------------------------------------- 1 | Glossary 2 | ======== 3 | 4 | .. Put definition of specific terms here, and reference them inside docs with :term:`My term` syntax 5 | 6 | .. glossary:: 7 | 8 | Authorization Server 9 | The authorization server asks resource owners for their consensus to let client applications access their data. 10 | It also manages and issues the tokens needed for all the authorization flows supported by OAuth2 spec. 11 | Usually the same application offering resources through an OAuth2-protected API also behaves like an 12 | authorization server. 13 | 14 | Resource Server 15 | An application providing access to its own resources through an API protected following the OAuth2 spec. 16 | 17 | Application 18 | An Application represents a Client on the Authorization server. Usually an Application is 19 | created manually by client's developers after logging in on an Authorization Server. 20 | 21 | Client 22 | A client is an application authorized to access OAuth2-protected resources on behalf and with the authorization 23 | of the resource owner. 24 | 25 | Resource Owner 26 | The user of an application which exposes resources to third party applications through OAuth2. The 27 | resource owner must give her authorization for third party applications to be able to access her data. 28 | 29 | Access Token 30 | A token needed to access resources protected by OAuth2. It has a lifetime which is usually quite short. 31 | 32 | Authorization Code 33 | The authorization code is obtained by using an authorization server as an intermediary between the client and 34 | resource owner. It is used to authenticate the client and grant the transmission of the Access Token. 35 | 36 | Authorization Token 37 | A token the authorization server issues to clients that can be swapped for an access token. It has a very short 38 | lifetime since the swap has to be performed shortly after users provide their authorization. 39 | 40 | Refresh Token 41 | A token the authorization server may issue to clients and can be swapped for a brand new access token, without 42 | repeating the authorization process. It has no expire time. 43 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django OAuth Toolkit documentation master file, created by 2 | sphinx-quickstart on Mon May 20 19:40:43 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django OAuth Toolkit Documentation 7 | ============================================= 8 | 9 | Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 10 | capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent 11 | `OAuthLib `_, so that everything is 12 | `rfc-compliant `_. 13 | 14 | See our :doc:`Changelog ` for information on updates. 15 | 16 | Support 17 | ------- 18 | 19 | If you need help please submit a `question `_. 20 | 21 | Requirements 22 | ------------ 23 | 24 | * Python 3.8+ 25 | * Django 4.2, 5.0 or 5.1 26 | * oauthlib 3.2.2+ 27 | 28 | Index 29 | ===== 30 | 31 | .. toctree:: 32 | :maxdepth: 2 33 | 34 | install 35 | getting_started 36 | tutorial/tutorial 37 | rest-framework/rest-framework 38 | views/views 39 | templates 40 | views/details 41 | models 42 | advanced_topics 43 | oidc 44 | signals 45 | settings 46 | resource_server 47 | management_commands 48 | glossary 49 | 50 | .. toctree:: 51 | :maxdepth: 1 52 | 53 | contributing 54 | changelog 55 | 56 | 57 | Indices and tables 58 | ================== 59 | 60 | * :ref:`genindex` 61 | * :ref:`modindex` 62 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install with pip:: 5 | 6 | pip install django-oauth-toolkit 7 | 8 | Add ``oauth2_provider`` to your ``INSTALLED_APPS`` 9 | 10 | .. code-block:: python 11 | 12 | INSTALLED_APPS = ( 13 | ... 14 | 'oauth2_provider', 15 | ) 16 | 17 | 18 | If you need an OAuth2 provider you'll want to add the following to your :file:`urls.py` 19 | 20 | .. code-block:: python 21 | 22 | from django.urls import include, path 23 | from oauth2_provider import urls as oauth2_urls 24 | 25 | urlpatterns = [ 26 | ... 27 | path('o/', include(oauth2_urls)), 28 | ] 29 | 30 | Sync your database 31 | ------------------ 32 | 33 | .. sourcecode:: sh 34 | 35 | python manage.py migrate oauth2_provider 36 | 37 | Next step is :doc:`getting started ` or :doc:`first tutorial `. 38 | -------------------------------------------------------------------------------- /docs/management_commands.rst: -------------------------------------------------------------------------------- 1 | Management commands 2 | =================== 3 | 4 | Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means such as cron 5 | or :doc:`Celery `. 6 | 7 | .. _cleartokens: 8 | .. _createapplication: 9 | 10 | 11 | cleartokens 12 | ~~~~~~~~~~~ 13 | 14 | The ``cleartokens`` management command allows the user to remove those refresh tokens whose lifetime is greater than the 15 | amount specified by ``REFRESH_TOKEN_EXPIRE_SECONDS`` settings. It is important that this command is run regularly 16 | (eg: via cron) to avoid cluttering the database with expired refresh tokens. 17 | 18 | If ``cleartokens`` runs daily the maximum delay before a refresh token is 19 | removed is ``REFRESH_TOKEN_EXPIRE_SECONDS`` + 1 day. This is normally not a 20 | problem since refresh tokens are long lived. 21 | 22 | To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRED_TOKENS_BATCH_SIZE`` and 23 | ``CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL`` settings to adjust the process speed. 24 | 25 | The ``cleartokens`` management command will also delete expired access and ID tokens alongside expired refresh tokens. 26 | 27 | Note: Refresh tokens need to expire before AccessTokens can be removed from the 28 | database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. 29 | 30 | 31 | 32 | createapplication 33 | ~~~~~~~~~~~~~~~~~ 34 | 35 | The ``createapplication`` management command provides a shortcut to create a new application in a programmatic way. 36 | 37 | .. code-block:: sh 38 | 39 | usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] 40 | [--redirect-uris REDIRECT_URIS] 41 | [--post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS] 42 | [--client-secret CLIENT_SECRET] 43 | [--name NAME] [--skip-authorization] 44 | [--algorithm ALGORITHM] [--version] 45 | [-v {0,1,2,3}] [--settings SETTINGS] 46 | [--pythonpath PYTHONPATH] [--traceback] 47 | [--no-color] [--force-color] 48 | [--skip-checks] 49 | client_type authorization_grant_type 50 | 51 | Shortcut to create a new application in a programmatic way 52 | 53 | positional arguments: 54 | client_type The client type, one of: confidential, public 55 | authorization_grant_type 56 | The type of authorization grant to be used, one of: 57 | authorization-code, implicit, password, client- 58 | credentials, openid-hybrid 59 | 60 | optional arguments: 61 | -h, --help show this help message and exit 62 | --client-id CLIENT_ID 63 | The ID of the new application 64 | --user USER The user the application belongs to 65 | --redirect-uris REDIRECT_URIS 66 | The redirect URIs, this must be a space separated 67 | string e.g 'URI1 URI2' 68 | --post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS 69 | The post logout redirect URIs, this must be a space 70 | separated string e.g 'URI1 URI2' 71 | --client-secret CLIENT_SECRET 72 | The secret for this application 73 | --name NAME The name this application 74 | --skip-authorization If set, completely bypass the authorization form, even 75 | on the first use of the application 76 | --algorithm ALGORITHM 77 | The OIDC token signing algorithm for this application, 78 | one of: RS256, HS256 79 | --version Show program's version number and exit. 80 | -v {0,1,2,3}, --verbosity {0,1,2,3} 81 | Verbosity level; 0=minimal output, 1=normal output, 82 | 2=verbose output, 3=very verbose output 83 | --settings SETTINGS The Python path to a settings module, e.g. 84 | "myproject.settings.main". If this isn't provided, the 85 | DJANGO_SETTINGS_MODULE environment variable will be 86 | used. 87 | --pythonpath PYTHONPATH 88 | A directory to add to the Python path, e.g. 89 | "/home/djangoprojects/myproject". 90 | --traceback Raise on CommandError exceptions. 91 | --no-color Don't colorize the command output. 92 | --force-color Force colorization of the command output. 93 | --skip-checks Skip system checks. 94 | 95 | If you let ``createapplication`` auto-generate the secret then it displays the value before hashing it. 96 | 97 | -------------------------------------------------------------------------------- /docs/models.rst: -------------------------------------------------------------------------------- 1 | Models 2 | ====== 3 | 4 | .. automodule:: oauth2_provider.models 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | oauthlib>=3.2.2 3 | m2r>=0.2.1 4 | mistune<2 5 | sphinx==7.2.6 6 | sphinx-rtd-theme==1.3.0 7 | -e . 8 | -------------------------------------------------------------------------------- /docs/resource_server.rst: -------------------------------------------------------------------------------- 1 | Separate Resource Server 2 | ======================== 3 | Django OAuth Toolkit allows to separate the :term:`Authorization Server` and the :term:`Resource Server`. 4 | Based on the `RFC 7662 `_ Django OAuth Toolkit provides 5 | a rfc-compliant introspection endpoint. 6 | As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. 7 | 8 | 9 | Setup the Authentication Server 10 | ------------------------------- 11 | Setup the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. 12 | Create a OAuth2 access token for the :term:`Resource Server` and add the 13 | ``introspection``-Scope to the settings. 14 | 15 | .. code-block:: python 16 | 17 | 'SCOPES': { 18 | 'read': 'Read scope', 19 | 'write': 'Write scope', 20 | 'introspection': 'Introspect token scope', 21 | ... 22 | }, 23 | 24 | The :term:`Authorization Server` will listen for introspection requests. 25 | The endpoint is located within the ``oauth2_provider.urls`` as ``/introspect/``. 26 | 27 | Example Request:: 28 | 29 | POST /o/introspect/ HTTP/1.1 30 | Host: server.example.com 31 | Accept: application/json 32 | Content-Type: application/x-www-form-urlencoded 33 | Authorization: Bearer 3yUqsWtwKYKHnfivFcJu 34 | 35 | token=uH3Po4KXWP4dsY4zgyxH 36 | 37 | Example Response:: 38 | 39 | HTTP/1.1 200 OK 40 | Content-Type: application/json 41 | 42 | { 43 | "active": true, 44 | "client_id": "oUdofn7rfhRtKWbmhyVk", 45 | "username": "jdoe", 46 | "scope": "read write dolphin", 47 | "exp": 1419356238 48 | } 49 | 50 | Setup the Resource Server 51 | ------------------------- 52 | Setup the :term:`Resource Server` like the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. 53 | Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and **either** ``RESOURCE_SERVER_AUTH_TOKEN`` 54 | **or** ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS`` as a ``(id,secret)`` tuple to your settings. 55 | The :term:`Resource Server` will try to verify its requests on the :term:`Authorization Server`. 56 | 57 | .. code-block:: python 58 | 59 | OAUTH2_PROVIDER = { 60 | ... 61 | 'RESOURCE_SERVER_INTROSPECTION_URL': 'https://example.org/o/introspect/', 62 | 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', # OR this but not both: 63 | # 'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': ('rs_client_id','rs_client_secret'), 64 | ... 65 | } 66 | 67 | ``RESOURCE_SERVER_INTROSPECTION_URL`` defines the introspection endpoint and 68 | ``RESOURCE_SERVER_AUTH_TOKEN`` an authentication token to authenticate against the 69 | :term:`Authorization Server`. 70 | As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authentication. 71 | For these, use: 72 | ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead 73 | of ``RESOURCE_SERVER_AUTH_TOKEN``. 74 | -------------------------------------------------------------------------------- /docs/rest-framework/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | title: songs 4 | version: v1 5 | components: 6 | securitySchemes: 7 | song_auth: 8 | type: oauth2 9 | flows: 10 | implicit: 11 | authorizationUrl: http://localhost:8000/o/authorize 12 | scopes: 13 | read: read about a song 14 | create: create a new song 15 | update: update an existing song 16 | delete: delete a song 17 | post: create a new song 18 | widget: widget scope 19 | scope2: scope too 20 | scope3: another scope 21 | paths: 22 | /songs: 23 | get: 24 | security: 25 | - song_auth: [read] 26 | responses: 27 | '200': 28 | description: A list of songs. 29 | post: 30 | security: 31 | - song_auth: [create] 32 | - song_auth: [post, widget] 33 | responses: 34 | '201': 35 | description: new song added 36 | put: 37 | security: 38 | - song_auth: [update] 39 | - song_auth: [put, widget] 40 | responses: 41 | '204': 42 | description: song updated 43 | delete: 44 | security: 45 | - song_auth: [delete] 46 | - song_auth: [scope2, scope3] 47 | responses: 48 | '200': 49 | description: song deleted 50 | -------------------------------------------------------------------------------- /docs/rest-framework/permissions.rst: -------------------------------------------------------------------------------- 1 | Permissions 2 | =========== 3 | 4 | Django OAuth Toolkit provides a few utility classes to use along with other permissions in Django REST Framework, 5 | so you can easily add scoped-based permission checks to your API views. 6 | 7 | More details on how to add custom permissions to your API Endpoints can be found at the official 8 | `Django REST Framework documentation `_ 9 | 10 | 11 | TokenHasScope 12 | ------------- 13 | 14 | The `TokenHasScope` permission class allows access only when the current access token has been 15 | authorized for **all** the scopes listed in the `required_scopes` field of the view. 16 | 17 | For example: 18 | 19 | .. code-block:: python 20 | 21 | class SongView(views.APIView): 22 | authentication_classes = [OAuth2Authentication] 23 | permission_classes = [TokenHasScope] 24 | required_scopes = ['music'] 25 | 26 | The `required_scopes` attribute is mandatory. 27 | 28 | 29 | TokenHasReadWriteScope 30 | ---------------------- 31 | 32 | The `TokenHasReadWriteScope` permission class allows access based on the `READ_SCOPE` and `WRITE_SCOPE` configured in the settings. 33 | 34 | When the current request's method is one of the "safe" methods `GET`, `HEAD`, `OPTIONS` 35 | the access is allowed only if the access token has been authorized for the `READ_SCOPE` scope. 36 | When the request's method is one of `POST`, `PUT`, `PATCH`, `DELETE` the access is allowed if the access token has been authorized for the `WRITE_SCOPE`. 37 | 38 | The `required_scopes` attribute is optional and can be used by other scopes needed in the view. 39 | 40 | For example: 41 | 42 | .. code-block:: python 43 | 44 | class SongView(views.APIView): 45 | authentication_classes = [OAuth2Authentication] 46 | permission_classes = [TokenHasReadWriteScope] 47 | required_scopes = ['music'] 48 | 49 | When a request is performed both the `READ_SCOPE` \\ `WRITE_SCOPE` and 'music' scopes are required to be authorized for the current access token. 50 | 51 | 52 | TokenHasResourceScope 53 | ---------------------- 54 | The `TokenHasResourceScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. 55 | 56 | When the current request's method is one of the "safe" methods, the access is allowed only if the access token has been authorized for the `scope:read` scope (for example `music:read`). 57 | When the request's method is one of "non safe" methods, the access is allowed only if the access token has been authorized for the `scope:write` scope (for example `music:write`). 58 | 59 | .. code-block:: python 60 | 61 | class SongView(views.APIView): 62 | authentication_classes = [OAuth2Authentication] 63 | permission_classes = [TokenHasResourceScope] 64 | required_scopes = ['music'] 65 | 66 | The `required_scopes` attribute is mandatory (you just need inform the resource scope). 67 | 68 | 69 | IsAuthenticatedOrTokenHasScope 70 | ------------------------------ 71 | The `IsAuthenticatedOrTokenHasScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according to the request's method. 72 | It also allows access to Authenticated users who are authenticated in django, but were not authenticated through the OAuth2Authentication class. 73 | This allows for protection of the API using scopes, but still let's users browse the full browsable API. 74 | To restrict users to only browse the parts of the browsable API they should be allowed to see, you can combine this with the DjangoModelPermission or the DjangoObjectPermission. 75 | 76 | For example: 77 | 78 | .. code-block:: python 79 | 80 | class SongView(views.APIView): 81 | permission_classes = [IsAuthenticatedOrTokenHasScope, DjangoModelPermission] 82 | required_scopes = ['music'] 83 | 84 | The `required_scopes` attribute is mandatory. 85 | 86 | 87 | TokenMatchesOASRequirements 88 | ------------------------------ 89 | 90 | The `TokenMatchesOASRequirements` permission class allows the access based on a per-method basis 91 | and with alternative lists of required scopes. This permission provides full functionality 92 | required by REST API specifications like the 93 | `OpenAPI Specification (OAS) security requirement object `_. 94 | 95 | The `required_alternate_scopes` attribute is a required map keyed by HTTP method name where each value is 96 | a list of alternative lists of required scopes. 97 | 98 | In the follow example GET requires "read" scope, POST requires either "create" scope **OR** "post" and "widget" scopes, 99 | etc. 100 | 101 | .. code-block:: python 102 | 103 | class SongView(views.APIView): 104 | authentication_classes = [OAuth2Authentication] 105 | permission_classes = [TokenMatchesOASRequirements] 106 | required_alternate_scopes = { 107 | "GET": [["read"]], 108 | "POST": [["create"], ["post", "widget"]], 109 | "PUT": [["update"], ["put", "widget"]], 110 | "DELETE": [["delete"], ["scope2", "scope3"]], 111 | } 112 | 113 | The following is a minimal OAS declaration that shows the same required alternate scopes. It is complete enough 114 | to try it in the `swagger editor `_. 115 | 116 | .. literalinclude:: openapi.yaml 117 | :language: YAML 118 | -------------------------------------------------------------------------------- /docs/rest-framework/rest-framework.rst: -------------------------------------------------------------------------------- 1 | Django Rest Framework 2 | --------------------- 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | getting_started 8 | permissions 9 | -------------------------------------------------------------------------------- /docs/rfc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom Sphinx documentation module to link to parts of the OAuth2 RFC. 3 | """ 4 | 5 | from docutils import nodes 6 | 7 | 8 | base_url = "https://rfc-editor.org/rfc/rfc6749.html" 9 | 10 | 11 | def rfclink(name, rawtext, text, lineno, inliner, options={}, content=[]): 12 | """Link to the OAuth2 draft. 13 | 14 | Returns 2 part tuple containing list of nodes to insert into the 15 | document and a list of system messages. Both are allowed to be 16 | empty. 17 | 18 | :param name: The role name used in the document. 19 | :param rawtext: The entire markup snippet, with role. 20 | :param text: The text marked with the role. 21 | :param lineno: The line number where rawtext appears in the input. 22 | :param inliner: The inliner instance that called us. 23 | :param options: Directive options for customization. 24 | :param content: The directive content for customization. 25 | """ 26 | 27 | node = nodes.reference(rawtext, "RFC6749 Section " + text, refuri="%s#section-%s" % (base_url, text)) 28 | 29 | return [node], [] 30 | 31 | 32 | def setup(app): 33 | """ 34 | Install the plugin. 35 | 36 | :param app: Sphinx application context. 37 | """ 38 | app.add_role("rfc", rfclink) 39 | -------------------------------------------------------------------------------- /docs/signals.rst: -------------------------------------------------------------------------------- 1 | Signals 2 | ======= 3 | 4 | Django-oauth-toolkit sends messages to various signals, depending on the action 5 | that has been triggered. 6 | 7 | You can easily import signals from ``oauth2_provider.signals`` and attach your 8 | own listeners. 9 | 10 | For example: 11 | 12 | .. code-block:: python 13 | 14 | from oauth2_provider.signals import app_authorized 15 | 16 | def handle_app_authorized(sender, request, token, **kwargs): 17 | print('App {} was authorized'.format(token.application.name)) 18 | 19 | app_authorized.connect(handle_app_authorized) 20 | 21 | Currently supported signals are: 22 | 23 | * ``oauth2_provider.signals.app_authorized`` - fired once an oauth code has been 24 | authorized and an access token has been granted 25 | -------------------------------------------------------------------------------- /docs/tutorial/admin+celery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/docs/tutorial/admin+celery.png -------------------------------------------------------------------------------- /docs/tutorial/celery+add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/docs/tutorial/celery+add.png -------------------------------------------------------------------------------- /docs/tutorial/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorials 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | tutorial_01 8 | tutorial_02 9 | tutorial_03 10 | tutorial_04 11 | tutorial_05 12 | 13 | -------------------------------------------------------------------------------- /docs/tutorial/tutorial_02.rst: -------------------------------------------------------------------------------- 1 | Part 2 - protect your APIs 2 | ========================== 3 | 4 | Scenario 5 | -------- 6 | It's very common for an :term:`Authorization Server` to also be the :term:`Resource Server`, usually exposing an API to 7 | let others access its own resources. Django OAuth Toolkit implements an easy way to protect the views of a Django 8 | application with OAuth2, in this tutorial we will see how to do it. 9 | 10 | Make your API 11 | ------------- 12 | We start where we left the :doc:`part 1 of the tutorial `: you have an authorization server and we want it 13 | to provide an API to access some kind of resources. We don't need an actual resource, so we will simply expose an 14 | endpoint protected with OAuth2: let's do it in a *class based view* fashion! 15 | 16 | Django OAuth Toolkit provides a set of generic class based view you can use to add OAuth behaviour to your views. Open 17 | your :file:`views.py` module and import the view: 18 | 19 | .. code-block:: python 20 | 21 | from oauth2_provider.views.generic import ProtectedResourceView 22 | from django.http import HttpResponse 23 | 24 | Then create the view which will respond to the API endpoint: 25 | 26 | .. code-block:: python 27 | 28 | class ApiEndpoint(ProtectedResourceView): 29 | def get(self, request, *args, **kwargs): 30 | return HttpResponse('Hello, OAuth2!') 31 | 32 | That's it, our API will expose only one method, responding to ``GET`` requests. Now open your :file:`urls.py` and specify the 33 | URL this view will respond to: 34 | 35 | .. code-block:: python 36 | 37 | from django.urls import path, include 38 | import oauth2_provider.views as oauth2_views 39 | from django.conf import settings 40 | from .views import ApiEndpoint 41 | 42 | # OAuth2 provider endpoints 43 | oauth2_endpoint_views = [ 44 | path('authorize/', oauth2_views.AuthorizationView.as_view(), name="authorize"), 45 | path('token/', oauth2_views.TokenView.as_view(), name="token"), 46 | path('revoke-token/', oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), 47 | ] 48 | 49 | if settings.DEBUG: 50 | # OAuth2 Application Management endpoints 51 | oauth2_endpoint_views += [ 52 | path('applications/', oauth2_views.ApplicationList.as_view(), name="list"), 53 | path('applications/register/', oauth2_views.ApplicationRegistration.as_view(), name="register"), 54 | path('applications//', oauth2_views.ApplicationDetail.as_view(), name="detail"), 55 | path('applications//delete/', oauth2_views.ApplicationDelete.as_view(), name="delete"), 56 | path('applications//update/', oauth2_views.ApplicationUpdate.as_view(), name="update"), 57 | ] 58 | 59 | # OAuth2 Token Management endpoints 60 | oauth2_endpoint_views += [ 61 | path('authorized-tokens/', oauth2_views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), 62 | path('authorized-tokens//delete/', oauth2_views.AuthorizedTokenDeleteView.as_view(), 63 | name="authorized-token-delete"), 64 | ] 65 | 66 | urlpatterns = [ 67 | # OAuth 2 endpoints: 68 | # need to pass in a tuple of the endpoints as well as the app's name 69 | # because the app_name attribute is not set in the included module 70 | path('o/', include((oauth2_endpoint_views, 'oauth2_provider'), namespace="oauth2_provider")), 71 | path('api/hello', ApiEndpoint.as_view()), # an example resource endpoint 72 | ] 73 | 74 | You will probably want to write your own application views to deal with permissions and access control but the ones packaged with the library can get you started when developing the app. 75 | 76 | Since we inherit from ``ProtectedResourceView``, we're done and our API is OAuth2 protected - for the sake of the lazy 77 | programmer. 78 | 79 | Testing your API 80 | ---------------- 81 | Time to make requests to your API. 82 | 83 | For a quick test, try accessing your app at the url ``/api/hello`` with your browser 84 | and verify that it responds with a ``403`` (in fact no ``HTTP_AUTHORIZATION`` header was provided). 85 | You can test your API with anything that can perform HTTP requests, but for this tutorial you can use the online 86 | `consumer client `_. 87 | Just fill the form with the URL of the API endpoint (i.e. http://localhost:8000/api/hello if you're on localhost) and 88 | the access token coming from the :doc:`part 1 of the tutorial `. Going in the Django admin and get the 89 | token from there is not considered cheating, so it's an option. 90 | 91 | Try performing a request and check that your :term:`Resource Server` aka :term:`Authorization Server` correctly responds with 92 | an HTTP 200. 93 | 94 | :doc:`Part 3 of the tutorial ` will show how to use an access token to authenticate 95 | users. 96 | -------------------------------------------------------------------------------- /docs/tutorial/tutorial_03.rst: -------------------------------------------------------------------------------- 1 | Part 3 - OAuth2 token authentication 2 | ==================================== 3 | 4 | Scenario 5 | -------- 6 | You want to use an :term:`Access Token` to authenticate users against Django's authentication 7 | system. 8 | 9 | Setup a provider 10 | ---------------- 11 | You need a fully-functional OAuth2 provider which is able to release access tokens: just follow 12 | the steps in :doc:`the part 1 of the tutorial `. To enable OAuth2 token authentication 13 | you need a middleware that checks for tokens inside requests and a custom authentication backend 14 | which takes care of token verification. In your settings.py: 15 | 16 | .. code-block:: python 17 | 18 | AUTHENTICATION_BACKENDS = [ 19 | 'oauth2_provider.backends.OAuth2Backend', 20 | # Uncomment following if you want to access the admin 21 | #'django.contrib.auth.backends.ModelBackend', 22 | '...', 23 | ] 24 | 25 | MIDDLEWARE = [ 26 | '...', 27 | # If you use AuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. 28 | # AuthenticationMiddleware is NOT required for using django-oauth-toolkit. 29 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 30 | 'oauth2_provider.middleware.OAuth2TokenMiddleware', 31 | '...', 32 | ] 33 | 34 | You will likely use the ``django.contrib.auth.backends.ModelBackend`` along with the OAuth2 backend 35 | (or you might not be able to log in into the admin), only pay attention to the order in which 36 | Django processes authentication backends. 37 | 38 | If you put the OAuth2 backend *after* the ``AuthenticationMiddleware`` and ``request.user`` is valid, 39 | the backend will do nothing; if ``request.user`` is the Anonymous user it will try to authenticate 40 | the user using the OAuth2 access token. 41 | 42 | If you put the OAuth2 backend *before* ``AuthenticationMiddleware``, or AuthenticationMiddleware is 43 | not used at all, it will try to authenticate user with the OAuth2 access token and set 44 | ``request.user`` and ``request._cached_user`` fields so that AuthenticationMiddleware (when active) 45 | will not try to get user from the session. 46 | 47 | If you use ``AuthenticationMiddleware``, be sure it appears before ``OAuth2TokenMiddleware``. 48 | However ``AuthenticationMiddleware`` is NOT required for using ``django-oauth-toolkit``. 49 | 50 | Note, ``OAuth2TokenMiddleware`` adds the user to the request object. There is also an optional ``OAuth2ExtraTokenMiddleware`` that adds the ``Token`` to the request. This makes it convenient to access the ``Application`` object within your views. To use it just add ``oauth2_provider.middleware.OAuth2ExtraTokenMiddleware`` to the ``MIDDLEWARE`` setting. 51 | 52 | Protect your view 53 | ----------------- 54 | The authentication backend will run smoothly with, for example, ``login_required`` decorators, so 55 | that you can have a view like this in your :file:`views.py` module: 56 | 57 | .. code-block:: python 58 | 59 | from django.contrib.auth.decorators import login_required 60 | from django.http.response import HttpResponse 61 | 62 | @login_required() 63 | def secret_page(request, *args, **kwargs): 64 | return HttpResponse('Secret contents!', status=200) 65 | 66 | To check everything works properly, mount the view above to some url: 67 | 68 | .. code-block:: python 69 | 70 | urlpatterns = [ 71 | path('secret', 'my.views.secret_page', name='secret'), 72 | '...', 73 | ] 74 | 75 | You should have an :term:`Application` registered at this point, if you don't, follow the steps in 76 | the previous tutorials to create one. Obtain an :term:`Access Token`, either following the OAuth2 77 | flow of your application or manually creating in the Django admin. 78 | Now supposing your access token value is ``123456`` you can try to access your authenticated view: 79 | 80 | :: 81 | 82 | curl -H "Authorization: Bearer 123456" -X GET http://localhost:8000/secret 83 | 84 | Working with Rest_framework generic class based views 85 | ----------------------------------------------------- 86 | 87 | If you have completed the `Django REST framework tutorial 88 | `_, 89 | you will be familiar with the 'Snippet' example, in particular the SnippetList and SnippetDetail classes. 90 | 91 | It would be nice to reuse those views **and** support token handling. Instead of reworking 92 | those classes to be ProtectedResourceView based, the solution is much simpler than that. 93 | 94 | Assume you have already modified the settings as was already shown. 95 | The key is setting a class attribute to override the default ``permissions_classes`` with something that will use our :term:`Access Token` properly. 96 | 97 | .. code-block:: python 98 | 99 | from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope 100 | 101 | class SnippetList(generics.ListCreateAPIView): 102 | ... 103 | permission_classes = [TokenHasReadWriteScope] 104 | 105 | class SnippetDetail(generics.ListCreateAPIView): 106 | ... 107 | permission_classes = [TokenHasReadWriteScope] 108 | 109 | Note that this example overrides the Django default permission class setting. There are several other 110 | ways this can be solved. Overriding the class function ``get_permission_classes`` is another way 111 | to solve the problem. 112 | 113 | A detailed dive into the `Django REST framework permissions is here. `_ 114 | 115 | -------------------------------------------------------------------------------- /docs/tutorial/tutorial_04.rst: -------------------------------------------------------------------------------- 1 | Part 4 - Revoking an OAuth2 Token 2 | ================================= 3 | 4 | Scenario 5 | -------- 6 | You've granted a user an :term:`Access Token`, following :doc:`part 1 ` and now you would like to revoke that token, probably in response to a client request (to logout). 7 | 8 | Revoking a Token 9 | ---------------- 10 | Be sure that you've granted a valid token. If you've hooked in ``oauth-toolkit`` into your :file:`urls.py` as specified in :doc:`part 1 `, you'll have a URL at ``/o/revoke_token``. By submitting the appropriate request to that URL, you can revoke a user's :term:`Access Token`. 11 | 12 | `Oauthlib `_ is compliant with https://rfc-editor.org/rfc/rfc7009.html, so as specified, the revocation request requires: 13 | 14 | - ``token``: REQUIRED, this is the :term:`Access Token` you want to revoke 15 | - ``token_type_hint``: OPTIONAL, designating either 'access_token' or 'refresh_token'. 16 | 17 | Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. 18 | 19 | Setup a Request 20 | --------------- 21 | Depending on the client type you're using, the token revocation request you may submit to the authentication server may vary. A `Public` client, for example, will not have access to your `Client Secret`. A revoke request from a public client would omit that secret, and take the form: 22 | 23 | :: 24 | 25 | POST /o/revoke_token/ HTTP/1.1 26 | Content-Type: application/x-www-form-urlencoded 27 | token=XXXX&client_id=XXXX 28 | 29 | Where token is :term:`Access Token` specified above, and client_id is the `Client id` obtained in 30 | obtained in :doc:`part 1 `. If your application type is `Confidential` , it requires a `Client secret`, you will have to add it as one of the parameters: 31 | 32 | :: 33 | 34 | POST /o/revoke_token/ HTTP/1.1 35 | Content-Type: application/x-www-form-urlencoded 36 | token=XXXX&client_id=XXXX&client_secret=XXXX 37 | 38 | 39 | The server will respond with a ``200`` status code on successful revocation. You can use ``curl`` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. 40 | 41 | :: 42 | 43 | curl --data "token=XXXX&client_id=XXXX&client_secret=XXXX" http://localhost:8000/o/revoke_token/ 44 | 45 | 46 | -------------------------------------------------------------------------------- /docs/views/application.rst: -------------------------------------------------------------------------------- 1 | Application Views 2 | ================= 3 | 4 | A set of views is provided to let users handle application instances without accessing Django Admin 5 | Site. Application views are listed at the url ``applications/`` and you can register a new one at the 6 | url ``applications/register``. You can override default templates located in 7 | :file:`templates/oauth2_provider` folder and provide a custom layout. Every view provides access only to 8 | data belonging to the logged in user who performs the request. 9 | 10 | 11 | .. automodule:: oauth2_provider.views.application 12 | :members: 13 | -------------------------------------------------------------------------------- /docs/views/class_based.rst: -------------------------------------------------------------------------------- 1 | Class-based Views 2 | ================= 3 | 4 | Django OAuth Toolkit provides generic classes useful to implement OAuth2 protected endpoints 5 | using the *Class Based View* approach. 6 | 7 | 8 | .. class:: ProtectedResourceView(ProtectedResourceMixin, View): 9 | 10 | A view that provides OAuth2 authentication out of the box. To implement a protected 11 | endpoint, just define your CBV as:: 12 | 13 | class MyEndpoint(ProtectedResourceView): 14 | """ 15 | A GET endpoint that needs OAuth2 authentication 16 | """ 17 | def get(self, request, *args, **kwargs): 18 | return HttpResponse('Hello, World!') 19 | 20 | **Please notice**: ``OPTION`` method is not OAuth2 protected to allow preflight requests. 21 | 22 | .. class:: ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView): 23 | 24 | A view that provides OAuth2 authentication and scopes handling out of the box. To implement 25 | a protected endpoint, just define your CBV specifying the ``required_scopes`` field:: 26 | 27 | class MyScopedEndpoint(ScopedProtectedResourceView): 28 | required_scopes = ['can_make_it can_break_it'] 29 | 30 | """ 31 | A GET endpoint that needs OAuth2 authentication 32 | and a set of scopes: 'can_make_it' and 'can_break_it' 33 | """ 34 | def get(self, request, *args, **kwargs): 35 | return HttpResponse('Hello, World!') 36 | 37 | 38 | .. class:: ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView): 39 | 40 | A view that provides OAuth2 authentication and read/write default scopes. 41 | ``GET``, ``HEAD``, ``OPTIONS`` HTTP methods require ``read`` scope, others methods 42 | need the ``write`` scope. If you need, you can always specify an additional list of 43 | scopes in the ``required_scopes`` field:: 44 | 45 | class MyRWEndpoint(ReadWriteScopedResourceView): 46 | required_scopes = ['has_additional_powers'] # optional 47 | 48 | """ 49 | A GET endpoint that needs OAuth2 authentication 50 | and the 'read' scope. If required_scopes was specified, 51 | clients also need those scopes. 52 | """ 53 | def get(self, request, *args, **kwargs): 54 | return HttpResponse('Hello, World!') 55 | 56 | 57 | Generic views in DOT are obtained composing a set of mixins you can find in the :doc:`views.mixins ` 58 | module: feel free to use those mixins directly if you want to provide your own class based views. 59 | -------------------------------------------------------------------------------- /docs/views/details.rst: -------------------------------------------------------------------------------- 1 | Views code and details 2 | ====================== 3 | 4 | 5 | Generic 6 | ------- 7 | Generic views are intended to use in a "batteries included" fashion to protect own views with OAuth2 authentication and 8 | Scopes handling. 9 | 10 | .. automodule:: oauth2_provider.views.generic 11 | :members: 12 | 13 | Mixins 14 | ------ 15 | These views are mainly for internal use, but advanced users may use them as basic components to customize OAuth2 logic 16 | inside their Django applications. 17 | 18 | .. automodule:: oauth2_provider.views.mixins 19 | :members: 20 | 21 | Base 22 | ---- 23 | Views needed to implement the main OAuth2 authorization flows supported by Django OAuth Toolkit. 24 | 25 | .. automodule:: oauth2_provider.views.base 26 | :members: 27 | -------------------------------------------------------------------------------- /docs/views/function_based.rst: -------------------------------------------------------------------------------- 1 | Function-based views 2 | ==================== 3 | 4 | Django OAuth Toolkit provides decorators to help you in protecting your function-based views. 5 | 6 | .. function:: protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server) 7 | 8 | Decorator to protect views by providing OAuth2 authentication out of the box, optionally with 9 | scope handling. Basic usage, without using scopes:: 10 | 11 | from oauth2_provider.decorators import protected_resource 12 | 13 | @protected_resource() 14 | def my_view(request): 15 | # An access token is required to get here... 16 | # ... 17 | pass 18 | 19 | If you want to check scopes as well when accessing a view you can pass them along as 20 | decorator's parameter:: 21 | 22 | from oauth2_provider.decorators import protected_resource 23 | 24 | @protected_resource(scopes=['can_make_it can_break_it']) 25 | def my_view(request): 26 | # An access token AND the right scopes are required to get here... 27 | # ... 28 | pass 29 | 30 | The decorator also accept server and validator classes if you want or need to use your own 31 | OAuth2 logic:: 32 | 33 | from oauth2_provider.decorators import protected_resource 34 | from myapp.oauth2_validators import MyValidator 35 | 36 | @protected_resource(validator_cls=MyValidator) 37 | def my_view(request): 38 | # You have to leverage your own logic to get here... 39 | # ... 40 | pass 41 | 42 | 43 | .. function:: rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server) 44 | 45 | Decorator to protect views by providing OAuth2 authentication and read/write scopes out of the 46 | box. ``GET``, ``HEAD``, ``OPTIONS`` HTTP methods require ``'read'`` scope. 47 | Otherwise ``'write'`` scope is required:: 48 | 49 | from oauth2_provider.decorators import rw_protected_resource 50 | 51 | @rw_protected_resource() 52 | def my_view(request): 53 | # If this is a POST, you have to provide 'write' scope to get here... 54 | # ... 55 | pass 56 | 57 | If you need, you can ask for other scopes over ``'read'`` and ``'write'``:: 58 | 59 | from oauth2_provider.decorators import rw_protected_resource 60 | 61 | @rw_protected_resource(scopes=['exotic_scope']) 62 | def my_view(request): 63 | # If this is a POST, you have to provide 'exotic_scope write' scopes to get here... 64 | # ... 65 | pass 66 | -------------------------------------------------------------------------------- /docs/views/mixins.rst: -------------------------------------------------------------------------------- 1 | Mixins for Class Based Views 2 | ============================ 3 | 4 | .. automodule:: oauth2_provider.views.mixins 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/views/token.rst: -------------------------------------------------------------------------------- 1 | Granted Tokens Views 2 | ==================== 3 | 4 | A set of views is provided to let users handle tokens that have been granted to them, without needing to accessing Django Admin Site. 5 | Every view provides access only to the tokens that have been granted to the user performing the request. 6 | 7 | 8 | Granted Token views are listed at the url ``authorized_tokens/``. 9 | 10 | 11 | For each granted token there is a delete view that allows you to delete such token. You can override default templates :file:`authorized-tokens.html` for the list view and :file:`authorized-token-delete.html` for the delete view; they are located inside :file:`templates/oauth2_provider` folder. 12 | 13 | 14 | .. automodule:: oauth2_provider.views.token 15 | :members: 16 | -------------------------------------------------------------------------------- /docs/views/views.rst: -------------------------------------------------------------------------------- 1 | Using the views 2 | =============== 3 | 4 | Django OAuth Toolkit provides a set of pre-defined views for different purposes: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | function_based 10 | class_based 11 | application 12 | token 13 | mixins 14 | -------------------------------------------------------------------------------- /oauth2_provider/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.0.1" 2 | -------------------------------------------------------------------------------- /oauth2_provider/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import get_user_model 3 | 4 | from oauth2_provider.models import ( 5 | get_access_token_admin_class, 6 | get_access_token_model, 7 | get_application_admin_class, 8 | get_application_model, 9 | get_grant_admin_class, 10 | get_grant_model, 11 | get_id_token_admin_class, 12 | get_id_token_model, 13 | get_refresh_token_admin_class, 14 | get_refresh_token_model, 15 | ) 16 | 17 | 18 | has_email = hasattr(get_user_model(), "email") 19 | 20 | 21 | class ApplicationAdmin(admin.ModelAdmin): 22 | list_display = ("pk", "name", "user", "client_type", "authorization_grant_type") 23 | list_filter = ("client_type", "authorization_grant_type", "skip_authorization") 24 | radio_fields = { 25 | "client_type": admin.HORIZONTAL, 26 | "authorization_grant_type": admin.VERTICAL, 27 | } 28 | search_fields = ("name",) + (("user__email",) if has_email else ()) 29 | raw_id_fields = ("user",) 30 | 31 | 32 | class AccessTokenAdmin(admin.ModelAdmin): 33 | list_display = ("token", "user", "application", "expires") 34 | list_select_related = ("application", "user") 35 | raw_id_fields = ("user", "source_refresh_token") 36 | search_fields = ("token",) + (("user__email",) if has_email else ()) 37 | list_filter = ("application",) 38 | 39 | 40 | class GrantAdmin(admin.ModelAdmin): 41 | list_display = ("code", "application", "user", "expires") 42 | raw_id_fields = ("user",) 43 | search_fields = ("code",) + (("user__email",) if has_email else ()) 44 | 45 | 46 | class IDTokenAdmin(admin.ModelAdmin): 47 | list_display = ("jti", "user", "application", "expires") 48 | raw_id_fields = ("user",) 49 | search_fields = ("user__email",) if has_email else () 50 | list_filter = ("application",) 51 | list_select_related = ("application", "user") 52 | 53 | 54 | class RefreshTokenAdmin(admin.ModelAdmin): 55 | list_display = ("token", "user", "application") 56 | raw_id_fields = ("user", "access_token") 57 | search_fields = ("token",) + (("user__email",) if has_email else ()) 58 | list_filter = ("application",) 59 | 60 | 61 | application_model = get_application_model() 62 | access_token_model = get_access_token_model() 63 | grant_model = get_grant_model() 64 | id_token_model = get_id_token_model() 65 | refresh_token_model = get_refresh_token_model() 66 | 67 | application_admin_class = get_application_admin_class() 68 | access_token_admin_class = get_access_token_admin_class() 69 | grant_admin_class = get_grant_admin_class() 70 | id_token_admin_class = get_id_token_admin_class() 71 | refresh_token_admin_class = get_refresh_token_admin_class() 72 | 73 | admin.site.register(application_model, application_admin_class) 74 | admin.site.register(access_token_model, access_token_admin_class) 75 | admin.site.register(grant_model, grant_admin_class) 76 | admin.site.register(id_token_model, id_token_admin_class) 77 | admin.site.register(refresh_token_model, refresh_token_admin_class) 78 | -------------------------------------------------------------------------------- /oauth2_provider/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DOTConfig(AppConfig): 5 | name = "oauth2_provider" 6 | verbose_name = "Django OAuth Toolkit" 7 | 8 | def ready(self): 9 | # Import checks to ensure they run. 10 | from . import checks # noqa: F401 11 | -------------------------------------------------------------------------------- /oauth2_provider/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.exceptions import SuspiciousOperation 3 | 4 | from .oauth2_backends import get_oauthlib_core 5 | 6 | 7 | UserModel = get_user_model() 8 | OAuthLibCore = get_oauthlib_core() 9 | 10 | 11 | class OAuth2Backend: 12 | """ 13 | Authenticate against an OAuth2 access token 14 | """ 15 | 16 | def authenticate(self, request=None, **credentials): 17 | if request is not None: 18 | try: 19 | valid, request = OAuthLibCore.verify_request(request, scopes=[]) 20 | except ValueError as error: 21 | if str(error) == "Invalid hex encoding in query string.": 22 | raise SuspiciousOperation(error) 23 | else: 24 | raise 25 | else: 26 | if valid: 27 | return request.user 28 | 29 | return None 30 | 31 | def get_user(self, user_id): 32 | try: 33 | return UserModel.objects.get(pk=user_id) 34 | except UserModel.DoesNotExist: 35 | return None 36 | -------------------------------------------------------------------------------- /oauth2_provider/checks.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.core import checks 3 | from django.db import router 4 | 5 | from .settings import oauth2_settings 6 | 7 | 8 | @checks.register(checks.Tags.database) 9 | def validate_token_configuration(app_configs, **kwargs): 10 | databases = set( 11 | router.db_for_write(apps.get_model(model)) 12 | for model in ( 13 | oauth2_settings.ACCESS_TOKEN_MODEL, 14 | oauth2_settings.ID_TOKEN_MODEL, 15 | oauth2_settings.REFRESH_TOKEN_MODEL, 16 | ) 17 | ) 18 | 19 | # This is highly unlikely, but let's warn people just in case it does. 20 | # If the tokens were allowed to be in different databases this would require all 21 | # writes to have a transaction around each database. Instead, let's enforce that 22 | # they all live together in one database. 23 | # The tokens are not required to live in the default database provided the Django 24 | # routers know the correct database for them. 25 | if len(databases) > 1: 26 | return [checks.Error("The token models are expected to be stored in the same database.")] 27 | 28 | return [] 29 | -------------------------------------------------------------------------------- /oauth2_provider/compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | The `compat` module provides support for backwards compatibility with older 3 | versions of Django and Python. 4 | """ 5 | 6 | try: 7 | # Django 5.1 introduced LoginRequiredMiddleware, and login_not_required decorator 8 | from django.contrib.auth.decorators import login_not_required 9 | except ImportError: 10 | 11 | def login_not_required(view_func): 12 | return view_func 13 | 14 | 15 | __all__ = ["login_not_required"] 16 | -------------------------------------------------------------------------------- /oauth2_provider/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/oauth2_provider/contrib/__init__.py -------------------------------------------------------------------------------- /oauth2_provider/contrib/rest_framework/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .authentication import OAuth2Authentication 3 | from .permissions import ( 4 | IsAuthenticatedOrTokenHasScope, 5 | TokenHasReadWriteScope, 6 | TokenHasResourceScope, 7 | TokenHasScope, 8 | TokenMatchesOASRequirements, 9 | ) 10 | -------------------------------------------------------------------------------- /oauth2_provider/contrib/rest_framework/authentication.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django.core.exceptions import SuspiciousOperation 4 | from rest_framework.authentication import BaseAuthentication 5 | 6 | from ...oauth2_backends import get_oauthlib_core 7 | 8 | 9 | class OAuth2Authentication(BaseAuthentication): 10 | """ 11 | OAuth 2 authentication backend using `django-oauth-toolkit` 12 | """ 13 | 14 | www_authenticate_realm = "api" 15 | 16 | def _dict_to_string(self, my_dict): 17 | """ 18 | Return a string of comma-separated key-value pairs (e.g. k="v",k2="v2"). 19 | """ 20 | return ",".join(['{k}="{v}"'.format(k=k, v=v) for k, v in my_dict.items()]) 21 | 22 | def authenticate(self, request): 23 | """ 24 | Returns two-tuple of (user, token) if authentication succeeds, 25 | or None otherwise. 26 | """ 27 | if request is None: 28 | return None 29 | oauthlib_core = get_oauthlib_core() 30 | try: 31 | valid, r = oauthlib_core.verify_request(request, scopes=[]) 32 | except ValueError as error: 33 | if str(error) == "Invalid hex encoding in query string.": 34 | raise SuspiciousOperation(error) 35 | raise 36 | else: 37 | if valid: 38 | return r.user, r.access_token 39 | request.oauth2_error = getattr(r, "oauth2_error", {}) 40 | return None 41 | 42 | def authenticate_header(self, request): 43 | """ 44 | Bearer is the only finalized type currently 45 | """ 46 | www_authenticate_attributes = OrderedDict( 47 | [ 48 | ("realm", self.www_authenticate_realm), 49 | ] 50 | ) 51 | oauth2_error = getattr(request, "oauth2_error", {}) 52 | www_authenticate_attributes.update(oauth2_error) 53 | return "Bearer {attributes}".format( 54 | attributes=self._dict_to_string(www_authenticate_attributes), 55 | ) 56 | -------------------------------------------------------------------------------- /oauth2_provider/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.http import HttpResponseForbidden 5 | from oauthlib.oauth2 import Server 6 | 7 | from .oauth2_backends import OAuthLibCore 8 | from .oauth2_validators import OAuth2Validator 9 | from .scopes import get_scopes_backend 10 | from .settings import oauth2_settings 11 | 12 | 13 | def protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): 14 | """ 15 | Decorator to protect views by providing OAuth2 authentication out of the box, 16 | optionally with scope handling. 17 | 18 | @protected_resource() 19 | def my_view(request): 20 | # An access token is required to get here... 21 | # ... 22 | pass 23 | """ 24 | _scopes = scopes or [] 25 | 26 | def decorator(view_func): 27 | @wraps(view_func) 28 | def _validate(request, *args, **kwargs): 29 | validator = validator_cls() 30 | core = OAuthLibCore(server_cls(validator)) 31 | valid, oauthlib_req = core.verify_request(request, scopes=_scopes) 32 | if valid: 33 | request.resource_owner = oauthlib_req.user 34 | return view_func(request, *args, **kwargs) 35 | return HttpResponseForbidden() 36 | 37 | return _validate 38 | 39 | return decorator 40 | 41 | 42 | def rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): 43 | """ 44 | Decorator to protect views by providing OAuth2 authentication and read/write scopes 45 | out of the box. 46 | GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. 47 | 48 | @rw_protected_resource() 49 | def my_view(request): 50 | # If this is a POST, you have to provide 'write' scope to get here... 51 | # ... 52 | pass 53 | 54 | """ 55 | _scopes = scopes or [] 56 | 57 | def decorator(view_func): 58 | @wraps(view_func) 59 | def _validate(request, *args, **kwargs): 60 | # Check if provided scopes are acceptable 61 | provided_scopes = get_scopes_backend().get_all_scopes() 62 | read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] 63 | 64 | if not set(read_write_scopes).issubset(set(provided_scopes)): 65 | raise ImproperlyConfigured( 66 | "rw_protected_resource decorator requires following scopes {0}" 67 | " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format(read_write_scopes) 68 | ) 69 | 70 | # Check if method is safe 71 | if request.method.upper() in ["GET", "HEAD", "OPTIONS"]: 72 | _scopes.append(oauth2_settings.READ_SCOPE) 73 | else: 74 | _scopes.append(oauth2_settings.WRITE_SCOPE) 75 | 76 | # proceed with validation 77 | validator = validator_cls() 78 | core = OAuthLibCore(server_cls(validator)) 79 | valid, oauthlib_req = core.verify_request(request, scopes=_scopes) 80 | if valid: 81 | request.resource_owner = oauthlib_req.user 82 | return view_func(request, *args, **kwargs) 83 | return HttpResponseForbidden() 84 | 85 | return _validate 86 | 87 | return decorator 88 | -------------------------------------------------------------------------------- /oauth2_provider/exceptions.py: -------------------------------------------------------------------------------- 1 | class OAuthToolkitError(Exception): 2 | """ 3 | Base class for exceptions 4 | """ 5 | 6 | def __init__(self, error=None, redirect_uri=None, *args, **kwargs): 7 | super().__init__(*args, **kwargs) 8 | self.oauthlib_error = error 9 | 10 | if redirect_uri: 11 | self.oauthlib_error.redirect_uri = redirect_uri 12 | 13 | 14 | class FatalClientError(OAuthToolkitError): 15 | """ 16 | Class for critical errors 17 | """ 18 | 19 | pass 20 | 21 | 22 | class OIDCError(Exception): 23 | """ 24 | General class to derive from for all OIDC related errors. 25 | """ 26 | 27 | status_code = 400 28 | error = None 29 | 30 | def __init__(self, description=None): 31 | if description is not None: 32 | self.description = description 33 | 34 | message = "({}) {}".format(self.error, self.description) 35 | super().__init__(message) 36 | 37 | 38 | class InvalidRequestFatalError(OIDCError): 39 | """ 40 | For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise 41 | incorrect requests. 42 | """ 43 | 44 | error = "invalid_request" 45 | 46 | 47 | class ClientIdMissmatch(InvalidRequestFatalError): 48 | description = "Mismatch between the Client ID of the ID Token and the Client ID that was provided." 49 | 50 | 51 | class InvalidOIDCClientError(InvalidRequestFatalError): 52 | description = "The client is unknown or no client has been included." 53 | 54 | 55 | class InvalidOIDCRedirectURIError(InvalidRequestFatalError): 56 | description = "Invalid post logout redirect URI." 57 | 58 | 59 | class InvalidIDTokenError(InvalidRequestFatalError): 60 | description = "The ID Token is expired, revoked, malformed, or otherwise invalid." 61 | 62 | 63 | class LogoutDenied(OIDCError): 64 | error = "logout_denied" 65 | description = "Logout has been refused by the user." 66 | -------------------------------------------------------------------------------- /oauth2_provider/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class AllowForm(forms.Form): 5 | allow = forms.BooleanField(required=False) 6 | redirect_uri = forms.CharField(widget=forms.HiddenInput()) 7 | scope = forms.CharField(widget=forms.HiddenInput()) 8 | nonce = forms.CharField(required=False, widget=forms.HiddenInput()) 9 | client_id = forms.CharField(widget=forms.HiddenInput()) 10 | state = forms.CharField(required=False, widget=forms.HiddenInput()) 11 | response_type = forms.CharField(widget=forms.HiddenInput()) 12 | code_challenge = forms.CharField(required=False, widget=forms.HiddenInput()) 13 | code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput()) 14 | claims = forms.CharField(required=False, widget=forms.HiddenInput()) 15 | 16 | 17 | class ConfirmLogoutForm(forms.Form): 18 | allow = forms.BooleanField(required=False) 19 | id_token_hint = forms.CharField(required=False, widget=forms.HiddenInput()) 20 | logout_hint = forms.CharField(required=False, widget=forms.HiddenInput()) 21 | client_id = forms.CharField(required=False, widget=forms.HiddenInput()) 22 | post_logout_redirect_uri = forms.CharField(required=False, widget=forms.HiddenInput()) 23 | state = forms.CharField(required=False, widget=forms.HiddenInput()) 24 | ui_locales = forms.CharField(required=False, widget=forms.HiddenInput()) 25 | 26 | def __init__(self, *args, **kwargs): 27 | self.request = kwargs.pop("request", None) 28 | super(ConfirmLogoutForm, self).__init__(*args, **kwargs) 29 | -------------------------------------------------------------------------------- /oauth2_provider/generators.py: -------------------------------------------------------------------------------- 1 | from oauthlib.common import UNICODE_ASCII_CHARACTER_SET 2 | from oauthlib.common import generate_client_id as oauthlib_generate_client_id 3 | 4 | from .settings import oauth2_settings 5 | 6 | 7 | class BaseHashGenerator: 8 | """ 9 | All generators should extend this class overriding `.hash()` method. 10 | """ 11 | 12 | def hash(self): 13 | raise NotImplementedError() 14 | 15 | 16 | class ClientIdGenerator(BaseHashGenerator): 17 | def hash(self): 18 | """ 19 | Generate a client_id for Basic Authentication scheme without colon char 20 | as in https://rfc-editor.org/rfc/rfc2617.html#section-2 21 | """ 22 | return oauthlib_generate_client_id(length=40, chars=UNICODE_ASCII_CHARACTER_SET) 23 | 24 | 25 | class ClientSecretGenerator(BaseHashGenerator): 26 | def hash(self): 27 | length = oauth2_settings.CLIENT_SECRET_GENERATOR_LENGTH 28 | chars = UNICODE_ASCII_CHARACTER_SET 29 | return oauthlib_generate_client_id(length=length, chars=chars) 30 | 31 | 32 | def generate_client_id(): 33 | """ 34 | Generate a suitable client id 35 | """ 36 | client_id_generator = oauth2_settings.CLIENT_ID_GENERATOR_CLASS() 37 | return client_id_generator.hash() 38 | 39 | 40 | def generate_client_secret(): 41 | """ 42 | Generate a suitable client secret 43 | """ 44 | client_secret_generator = oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() 45 | return client_secret_generator.hash() 46 | -------------------------------------------------------------------------------- /oauth2_provider/http.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | from django.core.exceptions import DisallowedRedirect 4 | from django.http import HttpResponse 5 | from django.utils.encoding import iri_to_uri 6 | 7 | 8 | class OAuth2ResponseRedirect(HttpResponse): 9 | """ 10 | An HTTP 302 redirect with an explicit list of allowed schemes. 11 | Works like django.http.HttpResponseRedirect but we customize it 12 | to give us more flexibility on allowed scheme validation. 13 | """ 14 | 15 | status_code = 302 16 | 17 | def __init__(self, redirect_to, allowed_schemes, *args, **kwargs): 18 | super().__init__(*args, **kwargs) 19 | self["Location"] = iri_to_uri(redirect_to) 20 | self.allowed_schemes = allowed_schemes 21 | self.validate_redirect(redirect_to) 22 | 23 | @property 24 | def url(self): 25 | return self["Location"] 26 | 27 | def validate_redirect(self, redirect_to): 28 | parsed = urlparse(str(redirect_to)) 29 | if not parsed.scheme: 30 | raise DisallowedRedirect("OAuth2 redirects require a URI scheme.") 31 | if parsed.scheme not in self.allowed_schemes: 32 | raise DisallowedRedirect("Redirect to scheme {!r} is not permitted".format(parsed.scheme)) 33 | -------------------------------------------------------------------------------- /oauth2_provider/locale/ja/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-11-28 09:45+0900\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Sora Yanai \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: ja-JP\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | #: models.py:66 21 | msgid "Confidential" 22 | msgstr "プライベート" 23 | 24 | #: models.py:67 25 | msgid "Public" 26 | msgstr "公開" 27 | 28 | #: models.py:76 29 | msgid "Authorization code" 30 | msgstr "認証コード" 31 | 32 | #: models.py:77 33 | msgid "Implicit" 34 | msgstr "Implicit Flow" 35 | 36 | #: models.py:78 37 | msgid "Resource owner password-based" 38 | msgstr "リソース所有者のパスワードに基づく" 39 | 40 | #: models.py:79 41 | msgid "Client credentials" 42 | msgstr "ユーザ証明書" 43 | 44 | #: models.py:80 45 | msgid "OpenID connect hybrid" 46 | msgstr "OpenID Connect ハイブリットフロー" 47 | 48 | #: models.py:87 49 | msgid "No OIDC support" 50 | msgstr "OIDCをサポートしない" 51 | 52 | #: models.py:88 53 | msgid "RSA with SHA-2 256" 54 | msgstr "RSA with SHA-2 256" 55 | 56 | #: models.py:89 57 | msgid "HMAC with SHA-2 256" 58 | msgstr "HMAC with SHA-2 256" 59 | 60 | #: models.py:104 61 | msgid "Allowed URIs list, space separated" 62 | msgstr "許可されるURLのリスト(半角スペース区切り)" 63 | 64 | #: models.py:113 65 | msgid "Hashed on Save. Copy it now if this is a new secret." 66 | msgstr "保存時にハッシュ化されます。新しいシークレットであれば、今すぐコピーしてください。" 67 | 68 | #: models.py:175 69 | #, python-brace-format 70 | msgid "Unauthorized redirect scheme: {scheme}" 71 | msgstr "{scheme} は許可されないリダイレクトスキームです" 72 | 73 | #: models.py:179 74 | #, python-brace-format 75 | msgid "redirect_uris cannot be empty with grant_type {grant_type}" 76 | msgstr "{grant_type} 認証タイプではリダイレクトURLを空欄にすることはできません" 77 | 78 | #: models.py:185 79 | msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" 80 | msgstr "RSAアルゴリズムを使用する場合はOIDC_RSA_PRIVATE_KEYを設定する必要があります" 81 | 82 | #: models.py:194 83 | msgid "You cannot use HS256 with public grants or clients" 84 | msgstr "HS256を公開認証やユーザに使用することはできません" 85 | 86 | #: oauth2_validators.py:211 87 | msgid "The access token is invalid." 88 | msgstr "アクセストークンが無効です。" 89 | 90 | #: oauth2_validators.py:218 91 | msgid "The access token has expired." 92 | msgstr "アクセストークンの有効期限が切れています。" 93 | 94 | #: oauth2_validators.py:225 95 | msgid "The access token is valid but does not have enough scope." 96 | msgstr "アクセストークンは有効ですが、十分な権限を持っていません。" 97 | 98 | #: templates/oauth2_provider/application_confirm_delete.html:6 99 | msgid "Are you sure to delete the application" 100 | msgstr "アプリケーションを本当に削除してよろしいでしょうか?" 101 | 102 | #: templates/oauth2_provider/application_confirm_delete.html:12 103 | #: templates/oauth2_provider/authorize.html:29 104 | msgid "Cancel" 105 | msgstr "キャンセル" 106 | 107 | #: templates/oauth2_provider/application_confirm_delete.html:13 108 | #: templates/oauth2_provider/application_detail.html:38 109 | #: templates/oauth2_provider/authorized-token-delete.html:7 110 | msgid "Delete" 111 | msgstr "削除" 112 | 113 | #: templates/oauth2_provider/application_detail.html:10 114 | msgid "Client id" 115 | msgstr "ユーザID" 116 | 117 | #: templates/oauth2_provider/application_detail.html:15 118 | msgid "Client secret" 119 | msgstr "ユーザパスワード" 120 | 121 | #: templates/oauth2_provider/application_detail.html:20 122 | msgid "Client type" 123 | msgstr "ユーザタイプ" 124 | 125 | #: templates/oauth2_provider/application_detail.html:25 126 | msgid "Authorization Grant Type" 127 | msgstr "認証方式" 128 | 129 | #: templates/oauth2_provider/application_detail.html:30 130 | msgid "Redirect Uris" 131 | msgstr "リダイレクトURL" 132 | 133 | #: templates/oauth2_provider/application_detail.html:36 134 | #: templates/oauth2_provider/application_form.html:35 135 | msgid "Go Back" 136 | msgstr "戻る" 137 | 138 | #: templates/oauth2_provider/application_detail.html:37 139 | msgid "Edit" 140 | msgstr "編集" 141 | 142 | #: templates/oauth2_provider/application_form.html:9 143 | msgid "Edit application" 144 | msgstr "アプリケーションを編集する" 145 | 146 | #: templates/oauth2_provider/application_form.html:37 147 | msgid "Save" 148 | msgstr "保存" 149 | 150 | #: templates/oauth2_provider/application_list.html:6 151 | msgid "Your applications" 152 | msgstr "アプリケーション" 153 | 154 | #: templates/oauth2_provider/application_list.html:14 155 | msgid "New Application" 156 | msgstr "新規アプリケーション" 157 | 158 | #: templates/oauth2_provider/application_list.html:17 159 | msgid "No applications defined" 160 | msgstr "アプリケーションがありません" 161 | 162 | #: templates/oauth2_provider/application_list.html:17 163 | msgid "Click here" 164 | msgstr "ここをクリック" 165 | 166 | #: templates/oauth2_provider/application_list.html:17 167 | msgid "if you want to register a new one" 168 | msgstr "して、新しいアプリケーションを登録" 169 | 170 | #: templates/oauth2_provider/application_registration_form.html:5 171 | msgid "Register a new application" 172 | msgstr "新規アプリケーションの登録" 173 | 174 | #: templates/oauth2_provider/authorize.html:8 175 | #: templates/oauth2_provider/authorize.html:30 176 | msgid "Authorize" 177 | msgstr "認証" 178 | 179 | #: templates/oauth2_provider/authorize.html:17 180 | msgid "Application requires the following permissions" 181 | msgstr "アプリケーションには以下の権限が必要です。" 182 | 183 | #: templates/oauth2_provider/authorized-token-delete.html:6 184 | msgid "Are you sure you want to delete this token?" 185 | msgstr "このトークンを本当に削除してよろしいですか?" 186 | 187 | #: templates/oauth2_provider/authorized-tokens.html:6 188 | msgid "Tokens" 189 | msgstr "トークン" 190 | 191 | #: templates/oauth2_provider/authorized-tokens.html:11 192 | msgid "revoke" 193 | msgstr "取り消す" 194 | 195 | #: templates/oauth2_provider/authorized-tokens.html:19 196 | msgid "There are no authorized tokens yet." 197 | msgstr "認証されたトークンはありません" 198 | -------------------------------------------------------------------------------- /oauth2_provider/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/oauth2_provider/management/__init__.py -------------------------------------------------------------------------------- /oauth2_provider/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/oauth2_provider/management/commands/__init__.py -------------------------------------------------------------------------------- /oauth2_provider/management/commands/cleartokens.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from ...models import clear_expired 4 | 5 | 6 | class Command(BaseCommand): # pragma: no cover 7 | help = "Can be run as a cronjob or directly to clean out expired tokens" 8 | 9 | def handle(self, *args, **options): 10 | clear_expired() 11 | -------------------------------------------------------------------------------- /oauth2_provider/management/commands/createapplication.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.core.management.base import BaseCommand 3 | 4 | from oauth2_provider.models import get_application_model 5 | 6 | 7 | Application = get_application_model() 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Shortcut to create a new application in a programmatic way" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "client_type", 16 | type=str, 17 | help="The client type, one of: %s" % ", ".join([ctype[0] for ctype in Application.CLIENT_TYPES]), 18 | ) 19 | parser.add_argument( 20 | "authorization_grant_type", 21 | type=str, 22 | help="The type of authorization grant to be used, one of: %s" 23 | % ", ".join([gtype[0] for gtype in Application.GRANT_TYPES]), 24 | ) 25 | parser.add_argument( 26 | "--client-id", 27 | type=str, 28 | help="The ID of the new application", 29 | ) 30 | parser.add_argument( 31 | "--user", 32 | type=str, 33 | help="The user the application belongs to", 34 | ) 35 | parser.add_argument( 36 | "--redirect-uris", 37 | type=str, 38 | help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'", 39 | ) 40 | parser.add_argument( 41 | "--post-logout-redirect-uris", 42 | type=str, 43 | help="The post logout redirect URIs, this must be a space separated string e.g 'URI1 URI2'", 44 | default="", 45 | ) 46 | parser.add_argument( 47 | "--client-secret", 48 | type=str, 49 | help="The secret for this application", 50 | ) 51 | parser.add_argument( 52 | "--no-hash-client-secret", 53 | dest="hash_client_secret", 54 | action="store_false", 55 | help="Don't hash the client secret", 56 | ) 57 | parser.set_defaults(hash_client_secret=True) 58 | parser.add_argument( 59 | "--name", 60 | type=str, 61 | help="The name this application", 62 | ) 63 | parser.add_argument( 64 | "--skip-authorization", 65 | action="store_true", 66 | help="If set, completely bypass the authorization form, even on the first use of the application", 67 | ) 68 | parser.add_argument( 69 | "--algorithm", 70 | type=str, 71 | help="The OIDC token signing algorithm for this application, one of: %s" 72 | % ", ".join([atype[0] for atype in Application.ALGORITHM_TYPES if atype[0]]), 73 | ) 74 | 75 | def handle(self, *args, **options): 76 | # Extract all fields related to the application, this will work now and in the future 77 | # and also with custom application models. 78 | application_fields = [field.name for field in Application._meta.fields] 79 | application_data = {} 80 | for key, value in options.items(): 81 | # Data in options must be cleaned because there are unneeded key-value like 82 | # verbosity and others. Also do not pass any None to the Application 83 | # instance so default values will be generated for those fields 84 | if key in application_fields and (isinstance(value, bool) or value): 85 | if key == "user": 86 | application_data.update({"user_id": value}) 87 | else: 88 | application_data.update({key: value}) 89 | 90 | new_application = Application(**application_data) 91 | 92 | try: 93 | new_application.full_clean() 94 | except ValidationError as exc: 95 | errors = "\n ".join( 96 | ["- " + err_key + ": " + str(err_value) for err_key, err_value in exc.message_dict.items()] 97 | ) 98 | self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors)) 99 | else: 100 | cleartext_secret = new_application.client_secret 101 | new_application.save() 102 | # Display the newly-created client_name or id. 103 | client_name_or_id = application_data.get("name", new_application.client_id) 104 | self.stdout.write( 105 | self.style.SUCCESS("New application %s created successfully." % client_name_or_id) 106 | ) 107 | # Print out the cleartext client_secret if it was autogenerated. 108 | if "client_secret" not in application_data: 109 | self.stdout.write(self.style.SUCCESS("client_secret: %s" % cleartext_secret)) 110 | -------------------------------------------------------------------------------- /oauth2_provider/middleware.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | 4 | from django.contrib.auth import authenticate 5 | from django.utils.cache import patch_vary_headers 6 | 7 | from oauth2_provider.models import get_access_token_model 8 | 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class OAuth2TokenMiddleware: 14 | """ 15 | Middleware for OAuth2 user authentication 16 | 17 | This middleware is able to work along with AuthenticationMiddleware and its behaviour depends 18 | on the order it's processed with. 19 | 20 | If it comes *after* AuthenticationMiddleware and request.user is valid, leave it as is and does 21 | not proceed with token validation. If request.user is the Anonymous user proceeds and try to 22 | authenticate the user using the OAuth2 access token. 23 | 24 | If it comes *before* AuthenticationMiddleware, or AuthenticationMiddleware is not used at all, 25 | tries to authenticate user with the OAuth2 access token and set request.user field. Setting 26 | also request._cached_user field makes AuthenticationMiddleware use that instead of the one from 27 | the session. 28 | 29 | It also adds "Authorization" to the "Vary" header, so that django's cache middleware or a 30 | reverse proxy can create proper cache keys. 31 | """ 32 | 33 | def __init__(self, get_response): 34 | self.get_response = get_response 35 | 36 | def __call__(self, request): 37 | # do something only if request contains a Bearer token 38 | if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"): 39 | if not hasattr(request, "user") or request.user.is_anonymous: 40 | user = authenticate(request=request) 41 | if user: 42 | request.user = request._cached_user = user 43 | 44 | response = self.get_response(request) 45 | patch_vary_headers(response, ("Authorization",)) 46 | return response 47 | 48 | 49 | class OAuth2ExtraTokenMiddleware: 50 | def __init__(self, get_response): 51 | self.get_response = get_response 52 | 53 | def __call__(self, request): 54 | authheader = request.META.get("HTTP_AUTHORIZATION", "") 55 | if authheader.startswith("Bearer"): 56 | tokenstring = authheader.split()[1] 57 | AccessToken = get_access_token_model() 58 | try: 59 | token_checksum = hashlib.sha256(tokenstring.encode("utf-8")).hexdigest() 60 | token = AccessToken.objects.get(token_checksum=token_checksum) 61 | request.access_token = token 62 | except AccessToken.DoesNotExist as e: 63 | log.exception(e) 64 | response = self.get_response(request) 65 | return response 66 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0002_auto_20190406_1805.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2019-04-06 18:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oauth2_provider', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='grant', 15 | name='code_challenge', 16 | field=models.CharField(blank=True, default='', max_length=128), 17 | ), 18 | migrations.AddField( 19 | model_name='grant', 20 | name='code_challenge_method', 21 | field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0003_auto_20201211_1314.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-11 13:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oauth2_provider', '0002_auto_20190406_1805'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='grant', 15 | name='redirect_uri', 16 | field=models.TextField(), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0004_auto_20200902_2022.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | from oauth2_provider.settings import oauth2_settings 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('oauth2_provider', '0003_auto_20201211_1314'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name='application', 20 | name='algorithm', 21 | field=models.CharField(blank=True, choices=[("", "No OIDC support"), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), 22 | ), 23 | migrations.AlterField( 24 | model_name='application', 25 | name='authorization_grant_type', 26 | field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), 27 | ), 28 | migrations.CreateModel( 29 | name='IDToken', 30 | fields=[ 31 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 32 | ("jti", models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID")), 33 | ('expires', models.DateTimeField()), 34 | ('scope', models.TextField(blank=True)), 35 | ('created', models.DateTimeField(auto_now_add=True)), 36 | ('updated', models.DateTimeField(auto_now=True)), 37 | ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), 38 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), 39 | ], 40 | options={ 41 | 'abstract': False, 42 | 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', 43 | }, 44 | ), 45 | migrations.AddField( 46 | model_name='accesstoken', 47 | name='id_token', 48 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), 49 | ), 50 | migrations.AddField( 51 | model_name="grant", 52 | name="nonce", 53 | field=models.CharField(blank=True, max_length=255, default=""), 54 | ), 55 | migrations.AddField( 56 | model_name="grant", 57 | name="claims", 58 | field=models.TextField(blank=True), 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0005_auto_20211222_2352.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.conf import settings 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ('oauth2_provider', '0004_auto_20200902_2022'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='accesstoken', 16 | name='user', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), 18 | ), 19 | migrations.AlterField( 20 | model_name='application', 21 | name='user', 22 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), 23 | ), 24 | migrations.AlterField( 25 | model_name='grant', 26 | name='user', 27 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), 28 | ), 29 | migrations.AlterField( 30 | model_name='idtoken', 31 | name='user', 32 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), 33 | ), 34 | migrations.AlterField( 35 | model_name='refreshtoken', 36 | name='user', 37 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0006_alter_application_client_secret.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db import migrations 4 | 5 | import oauth2_provider.generators 6 | import oauth2_provider.models 7 | from oauth2_provider import settings 8 | 9 | 10 | logger = logging.getLogger() 11 | 12 | 13 | def forwards_func(apps, schema_editor): 14 | """ 15 | Forward migration touches every application.client_secret which will cause it to be hashed if not already the case. 16 | """ 17 | Application = apps.get_model(settings.APPLICATION_MODEL) 18 | applications = Application._default_manager.all() 19 | for application in applications: 20 | application.save(update_fields=['client_secret']) 21 | 22 | 23 | def reverse_func(apps, schema_editor): 24 | warning_color_code = "\033[93m" 25 | end_color_code = "\033[0m" 26 | msg = f"\n{warning_color_code}The previously hashed client_secret cannot be reverted, and it remains hashed{end_color_code}" 27 | logger.warning(msg) 28 | 29 | 30 | class Migration(migrations.Migration): 31 | 32 | dependencies = [ 33 | ('oauth2_provider', '0005_auto_20211222_2352'), 34 | ] 35 | 36 | operations = [ 37 | migrations.AlterField( 38 | model_name='application', 39 | name='client_secret', 40 | field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), 41 | ), 42 | migrations.RunPython(forwards_func, reverse_func), 43 | ] 44 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-14 12:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("oauth2_provider", "0006_alter_application_client_secret"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="application", 15 | name="post_logout_redirect_uris", 16 | field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated", default=""), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0008_alter_accesstoken_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-09-11 07:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("oauth2_provider", "0007_application_post_logout_redirect_uris"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="accesstoken", 14 | name="token", 15 | field=models.CharField(db_index=True, max_length=255, unique=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0009_add_hash_client_secret.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-09-07 19:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oauth2_provider', '0008_alter_accesstoken_token'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='application', 15 | name='hash_client_secret', 16 | field=models.BooleanField(default=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0010_application_allowed_origins.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-09-27 20:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("oauth2_provider", "0009_add_hash_client_secret"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="application", 15 | name="allowed_origins", 16 | field=models.TextField( 17 | blank=True, 18 | help_text="Allowed origins list to enable CORS, space separated", 19 | default="", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0011_refreshtoken_token_family.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2 on 2024-08-09 16:40 2 | 3 | from django.db import migrations, models 4 | from oauth2_provider.settings import oauth2_settings 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oauth2_provider', '0010_application_allowed_origins'), 10 | migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL) 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='refreshtoken', 16 | name='token_family', 17 | field=models.UUIDField(blank=True, editable=False, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/0012_add_token_checksum.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-29 23:13 2 | 3 | import oauth2_provider.models 4 | from django.db import migrations, models 5 | from oauth2_provider.settings import oauth2_settings 6 | 7 | def forwards_func(apps, schema_editor): 8 | """ 9 | Forward migration touches every "old" accesstoken.token which will cause the checksum to be computed. 10 | """ 11 | AccessToken = apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) 12 | accesstokens = AccessToken._default_manager.iterator() 13 | for accesstoken in accesstokens: 14 | accesstoken.save(update_fields=['token_checksum']) 15 | 16 | 17 | class Migration(migrations.Migration): 18 | dependencies = [ 19 | ("oauth2_provider", "0011_refreshtoken_token_family"), 20 | migrations.swappable_dependency(oauth2_settings.ACCESS_TOKEN_MODEL), 21 | ] 22 | 23 | operations = [ 24 | migrations.AddField( 25 | model_name="accesstoken", 26 | name="token_checksum", 27 | field=oauth2_provider.models.TokenChecksumField(blank=True, null=True, max_length=64), 28 | ), 29 | migrations.AlterField( 30 | model_name="accesstoken", 31 | name="token", 32 | field=models.TextField(), 33 | ), 34 | migrations.RunPython(forwards_func, migrations.RunPython.noop), 35 | migrations.AlterField( 36 | model_name='accesstoken', 37 | name='token_checksum', 38 | field=oauth2_provider.models.TokenChecksumField(blank=False, max_length=64, db_index=True, unique=True), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /oauth2_provider/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/oauth2_provider/migrations/__init__.py -------------------------------------------------------------------------------- /oauth2_provider/scopes.py: -------------------------------------------------------------------------------- 1 | from .settings import oauth2_settings 2 | 3 | 4 | class BaseScopes: 5 | def get_all_scopes(self): 6 | """ 7 | Return a dict-like object with all the scopes available in the 8 | system. The key should be the scope name and the value should be 9 | the description. 10 | 11 | ex: {"read": "A read scope", "write": "A write scope"} 12 | """ 13 | raise NotImplementedError("") 14 | 15 | def get_available_scopes(self, application=None, request=None, *args, **kwargs): 16 | """ 17 | Return a list of scopes available for the current application/request. 18 | 19 | TODO: add info on where and why this method is called. 20 | 21 | ex: ["read", "write"] 22 | """ 23 | raise NotImplementedError("") 24 | 25 | def get_default_scopes(self, application=None, request=None, *args, **kwargs): 26 | """ 27 | Return a list of the default scopes for the current application/request. 28 | This MUST be a subset of the scopes returned by `get_available_scopes`. 29 | 30 | TODO: add info on where and why this method is called. 31 | 32 | ex: ["read"] 33 | """ 34 | raise NotImplementedError("") 35 | 36 | 37 | class SettingsScopes(BaseScopes): 38 | def get_all_scopes(self): 39 | return oauth2_settings.SCOPES 40 | 41 | def get_available_scopes(self, application=None, request=None, *args, **kwargs): 42 | return oauth2_settings._SCOPES 43 | 44 | def get_default_scopes(self, application=None, request=None, *args, **kwargs): 45 | return oauth2_settings._DEFAULT_SCOPES 46 | 47 | 48 | def get_scopes_backend(): 49 | scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS 50 | return scopes_class() 51 | -------------------------------------------------------------------------------- /oauth2_provider/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | app_authorized = Signal() # providing_args=["request", "token"] 5 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/application_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
6 |

{% trans "Are you sure to delete the application" %} {{ application.name }}?

7 |
8 | {% csrf_token %} 9 | 10 |
11 |
12 | {% trans "Cancel" %} 13 | 14 |
15 |
16 |
17 |
18 | {% endblock content %} 19 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/application_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
6 |

{{ application.name }}

7 | 8 |
    9 |
  • 10 |

    {% trans "Client id" %}

    11 | 12 |
  • 13 | 14 |
  • 15 |

    {% trans "Client secret" %}

    16 | 17 |
  • 18 | 19 |
  • 20 |

    {% trans "Hash client secret" %}

    21 |

    {{ application.hash_client_secret|yesno:_("yes,no") }}

    22 |
  • 23 | 24 |
  • 25 |

    {% trans "Client type" %}

    26 |

    {{ application.client_type }}

    27 |
  • 28 | 29 |
  • 30 |

    {% trans "Authorization Grant Type" %}

    31 |

    {{ application.authorization_grant_type }}

    32 |
  • 33 | 34 |
  • 35 |

    {% trans "Redirect Uris" %}

    36 | 37 |
  • 38 | 39 |
  • 40 |

    {% trans "Post Logout Redirect Uris" %}

    41 | 42 |
  • 43 | 44 |
  • 45 |

    {% trans "Allowed Origins" %}

    46 | 47 |
  • 48 |
49 | 50 | 55 |
56 | {% endblock content %} 57 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/application_form.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
6 |
7 |

8 | {% block app-form-title %} 9 | {% trans "Edit application" %} {{ application.name }} 10 | {% endblock app-form-title %} 11 |

12 | {% csrf_token %} 13 | 14 | {% for field in form %} 15 |
16 | 17 |
18 | {{ field }} 19 | {% for error in field.errors %} 20 | {{ error }} 21 | {% endfor %} 22 |
23 |
24 | {% endfor %} 25 | 26 |
27 | {% for error in form.non_field_errors %} 28 | {{ error }} 29 | {% endfor %} 30 |
31 | 32 |
33 |
34 | 35 | {% trans "Go Back" %} 36 | 37 | 38 |
39 |
40 |
41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/application_list.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
6 |

{% trans "Your applications" %}

7 | {% if applications %} 8 | 13 | 14 | {% trans "New Application" %} 15 | {% else %} 16 | 17 |

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

18 | {% endif %} 19 |
20 | {% endblock content %} 21 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/application_registration_form.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/application_form.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block app-form-title %}{% trans "Register a new application" %}{% endblock app-form-title %} 6 | 7 | {% block app-form-action-url %}{% url 'oauth2_provider:register' %}{% endblock app-form-action-url %} 8 | 9 | {% block app-form-back-url %}{% url "oauth2_provider:list" %}"{% endblock app-form-back-url %} 10 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/authorize.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
6 | {% if not error %} 7 |
8 |

{% trans "Authorize" %} {{ application.name }}?

9 | {% csrf_token %} 10 | 11 | {% for field in form %} 12 | {% if field.is_hidden %} 13 | {{ field }} 14 | {% endif %} 15 | {% endfor %} 16 | 17 |

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

18 |
    19 | {% for scope in scopes_descriptions %} 20 |
  • {{ scope }}
  • 21 | {% endfor %} 22 |
23 | 24 | {{ form.errors }} 25 | {{ form.non_field_errors }} 26 | 27 |
28 |
29 | 30 | 31 |
32 |
33 |
34 | 35 | {% else %} 36 |

Error: {{ error.error }}

37 |

{{ error.description }}

38 | {% endif %} 39 |
40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/authorized-token-delete.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
{% csrf_token %} 6 |

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

7 | 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/authorized-tokens.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
6 |

{% trans "Tokens" %}

7 |
    8 | {% for authorized_token in authorized_tokens %} 9 |
  • 10 | {{ authorized_token.application }} 11 | ({% trans "revoke" %}) 12 |
  • 13 |
      14 | {% for scope_name, scope_description in authorized_token.scopes.items %} 15 |
    • {{ scope_name }}: {{ scope_description }}
    • 16 | {% endfor %} 17 |
    18 | {% empty %} 19 |
  • {% trans "There are no authorized tokens yet." %}
  • 20 | {% endfor %} 21 |
22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}{% endblock title %} 6 | 7 | 8 | 9 | 10 | {% block css %} 11 | 12 | {% endblock css %} 13 | 14 | 38 | 39 | 40 | 41 | 42 |
43 | {% block content %} 44 | {% endblock content %} 45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /oauth2_provider/templates/oauth2_provider/logout_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth2_provider/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block content %} 5 |
6 | {% if not error %} 7 |
8 | {% if application %} 9 |

Confirm Logout requested by {{ application.name }}

10 | {% else %} 11 |

Confirm Logout

12 | {% endif %} 13 | {% csrf_token %} 14 | 15 | {% for field in form %} 16 | {% if field.is_hidden %} 17 | {{ field }} 18 | {% endif %} 19 | {% endfor %} 20 | 21 | {{ form.errors }} 22 | {{ form.non_field_errors }} 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 |
31 | 32 | {% else %} 33 |

Error: {{ error.error }}

34 |

{{ error.description }}

35 | {% endif %} 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /oauth2_provider/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from . import views 4 | 5 | 6 | app_name = "oauth2_provider" 7 | 8 | 9 | base_urlpatterns = [ 10 | path("authorize/", views.AuthorizationView.as_view(), name="authorize"), 11 | path("token/", views.TokenView.as_view(), name="token"), 12 | path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), 13 | path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), 14 | ] 15 | 16 | 17 | management_urlpatterns = [ 18 | # Application management views 19 | path("applications/", views.ApplicationList.as_view(), name="list"), 20 | path("applications/register/", views.ApplicationRegistration.as_view(), name="register"), 21 | path("applications//", views.ApplicationDetail.as_view(), name="detail"), 22 | path("applications//delete/", views.ApplicationDelete.as_view(), name="delete"), 23 | path("applications//update/", views.ApplicationUpdate.as_view(), name="update"), 24 | # Token management views 25 | path("authorized_tokens/", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), 26 | path( 27 | "authorized_tokens//delete/", 28 | views.AuthorizedTokenDeleteView.as_view(), 29 | name="authorized-token-delete", 30 | ), 31 | ] 32 | 33 | oidc_urlpatterns = [ 34 | # .well-known/openid-configuration/ is deprecated 35 | # https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig 36 | # does not specify a trailing slash 37 | # Support for trailing slash shall be removed in a future release. 38 | re_path( 39 | r"^\.well-known/openid-configuration/?$", 40 | views.ConnectDiscoveryInfoView.as_view(), 41 | name="oidc-connect-discovery-info", 42 | ), 43 | path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"), 44 | path("userinfo/", views.UserInfoView.as_view(), name="user-info"), 45 | path("logout/", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"), 46 | ] 47 | 48 | 49 | urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns 50 | -------------------------------------------------------------------------------- /oauth2_provider/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from django.conf import settings 4 | from jwcrypto import jwk 5 | 6 | 7 | @functools.lru_cache() 8 | def jwk_from_pem(pem_string): 9 | """ 10 | A cached version of jwcrypto.JWK.from_pem. 11 | Converting from PEM is expensive for large keys such as those using RSA. 12 | """ 13 | return jwk.JWK.from_pem(pem_string.encode("utf-8")) 14 | 15 | 16 | # @functools.lru_cache 17 | def get_timezone(time_zone): 18 | """ 19 | Return the default time zone as a tzinfo instance. 20 | 21 | This is the time zone defined by settings.TIME_ZONE. 22 | """ 23 | try: 24 | import zoneinfo 25 | except ImportError: 26 | import pytz 27 | 28 | return pytz.timezone(time_zone) 29 | else: 30 | if getattr(settings, "USE_DEPRECATED_PYTZ", False): 31 | import pytz 32 | 33 | return pytz.timezone(time_zone) 34 | return zoneinfo.ZoneInfo(time_zone) 35 | -------------------------------------------------------------------------------- /oauth2_provider/views/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .base import AuthorizationView, TokenView, RevokeTokenView # isort:skip 3 | from .application import ( 4 | ApplicationDelete, 5 | ApplicationDetail, 6 | ApplicationList, 7 | ApplicationRegistration, 8 | ApplicationUpdate, 9 | ) 10 | from .generic import ( 11 | ClientProtectedResourceView, 12 | ClientProtectedScopedResourceView, 13 | ProtectedResourceView, 14 | ReadWriteScopedResourceView, 15 | ScopedProtectedResourceView, 16 | ) 17 | from .introspect import IntrospectTokenView 18 | from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView 19 | from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView 20 | -------------------------------------------------------------------------------- /oauth2_provider/views/application.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.forms.models import modelform_factory 3 | from django.urls import reverse_lazy 4 | from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView 5 | 6 | from ..models import get_application_model 7 | 8 | 9 | class ApplicationOwnerIsUserMixin(LoginRequiredMixin): 10 | """ 11 | This mixin is used to provide an Application queryset filtered by the current request.user. 12 | """ 13 | 14 | fields = "__all__" 15 | 16 | def get_queryset(self): 17 | return get_application_model().objects.filter(user=self.request.user) 18 | 19 | 20 | class ApplicationRegistration(LoginRequiredMixin, CreateView): 21 | """ 22 | View used to register a new Application for the request.user 23 | """ 24 | 25 | template_name = "oauth2_provider/application_registration_form.html" 26 | 27 | def get_form_class(self): 28 | """ 29 | Returns the form class for the application model 30 | """ 31 | return modelform_factory( 32 | get_application_model(), 33 | fields=( 34 | "name", 35 | "client_id", 36 | "client_secret", 37 | "hash_client_secret", 38 | "client_type", 39 | "authorization_grant_type", 40 | "redirect_uris", 41 | "post_logout_redirect_uris", 42 | "allowed_origins", 43 | "algorithm", 44 | ), 45 | ) 46 | 47 | def form_valid(self, form): 48 | form.instance.user = self.request.user 49 | return super().form_valid(form) 50 | 51 | 52 | class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView): 53 | """ 54 | Detail view for an application instance owned by the request.user 55 | """ 56 | 57 | context_object_name = "application" 58 | template_name = "oauth2_provider/application_detail.html" 59 | 60 | 61 | class ApplicationList(ApplicationOwnerIsUserMixin, ListView): 62 | """ 63 | List view for all the applications owned by the request.user 64 | """ 65 | 66 | context_object_name = "applications" 67 | template_name = "oauth2_provider/application_list.html" 68 | 69 | 70 | class ApplicationDelete(ApplicationOwnerIsUserMixin, DeleteView): 71 | """ 72 | View used to delete an application owned by the request.user 73 | """ 74 | 75 | context_object_name = "application" 76 | success_url = reverse_lazy("oauth2_provider:list") 77 | template_name = "oauth2_provider/application_confirm_delete.html" 78 | 79 | 80 | class ApplicationUpdate(ApplicationOwnerIsUserMixin, UpdateView): 81 | """ 82 | View used to update an application owned by the request.user 83 | """ 84 | 85 | context_object_name = "application" 86 | template_name = "oauth2_provider/application_form.html" 87 | 88 | def get_form_class(self): 89 | """ 90 | Returns the form class for the application model 91 | """ 92 | return modelform_factory( 93 | get_application_model(), 94 | fields=( 95 | "name", 96 | "client_id", 97 | "client_secret", 98 | "hash_client_secret", 99 | "client_type", 100 | "authorization_grant_type", 101 | "redirect_uris", 102 | "post_logout_redirect_uris", 103 | "allowed_origins", 104 | "algorithm", 105 | ), 106 | ) 107 | -------------------------------------------------------------------------------- /oauth2_provider/views/generic.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import View 2 | 3 | from .mixins import ( 4 | ClientProtectedResourceMixin, 5 | ProtectedResourceMixin, 6 | ReadWriteScopedResourceMixin, 7 | ScopedResourceMixin, 8 | ) 9 | 10 | 11 | class ProtectedResourceView(ProtectedResourceMixin, View): 12 | """ 13 | Generic view protecting resources by providing OAuth2 authentication out of the box 14 | """ 15 | 16 | pass 17 | 18 | 19 | class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView): 20 | """ 21 | Generic view protecting resources by providing OAuth2 authentication and Scopes handling 22 | out of the box 23 | """ 24 | 25 | pass 26 | 27 | 28 | class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView): 29 | """ 30 | Generic view protecting resources with OAuth2 authentication and read/write scopes. 31 | GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. 32 | """ 33 | 34 | pass 35 | 36 | 37 | class ClientProtectedResourceView(ClientProtectedResourceMixin, View): 38 | """View for protecting a resource with client-credentials method. 39 | This involves allowing access tokens, Basic Auth and plain credentials in request body. 40 | """ 41 | 42 | pass 43 | 44 | 45 | class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView): 46 | """Impose scope restrictions if client protection fallsback to access token.""" 47 | 48 | pass 49 | -------------------------------------------------------------------------------- /oauth2_provider/views/introspect.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import hashlib 3 | 4 | from django.core.exceptions import ObjectDoesNotExist 5 | from django.http import JsonResponse 6 | from django.utils.decorators import method_decorator 7 | from django.views.decorators.csrf import csrf_exempt 8 | 9 | from ..compat import login_not_required 10 | from ..models import get_access_token_model 11 | from ..views.generic import ClientProtectedScopedResourceView 12 | 13 | 14 | @method_decorator(csrf_exempt, name="dispatch") 15 | @method_decorator(login_not_required, name="dispatch") 16 | class IntrospectTokenView(ClientProtectedScopedResourceView): 17 | """ 18 | Implements an endpoint for token introspection based 19 | on RFC 7662 https://rfc-editor.org/rfc/rfc7662.html 20 | 21 | To access this view the request must pass a OAuth2 Bearer Token 22 | which is allowed to access the scope `introspection`. 23 | """ 24 | 25 | required_scopes = ["introspection"] 26 | 27 | @staticmethod 28 | def get_token_response(token_value=None): 29 | try: 30 | token_checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest() 31 | token = ( 32 | get_access_token_model() 33 | .objects.select_related("user", "application") 34 | .get(token_checksum=token_checksum) 35 | ) 36 | except ObjectDoesNotExist: 37 | return JsonResponse({"active": False}, status=200) 38 | else: 39 | if token.is_valid(): 40 | data = { 41 | "active": True, 42 | "scope": token.scope, 43 | "exp": int(calendar.timegm(token.expires.timetuple())), 44 | } 45 | if token.application: 46 | data["client_id"] = token.application.client_id 47 | if token.user: 48 | data["username"] = token.user.get_username() 49 | return JsonResponse(data) 50 | else: 51 | return JsonResponse({"active": False}, status=200) 52 | 53 | def get(self, request, *args, **kwargs): 54 | """ 55 | Get the token from the URL parameters. 56 | URL: https://example.com/introspect?token=mF_9.B5f-4.1JqM 57 | 58 | :param request: 59 | :param args: 60 | :param kwargs: 61 | :return: 62 | """ 63 | return self.get_token_response(request.GET.get("token", None)) 64 | 65 | def post(self, request, *args, **kwargs): 66 | """ 67 | Get the token from the body form parameters. 68 | Body: token=mF_9.B5f-4.1JqM 69 | 70 | :param request: 71 | :param args: 72 | :param kwargs: 73 | :return: 74 | """ 75 | return self.get_token_response(request.POST.get("token", None)) 76 | -------------------------------------------------------------------------------- /oauth2_provider/views/token.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.urls import reverse_lazy 3 | from django.views.generic import DeleteView, ListView 4 | 5 | from ..models import get_access_token_model 6 | 7 | 8 | class AuthorizedTokensListView(LoginRequiredMixin, ListView): 9 | """ 10 | Show a page where the current logged-in user can see his tokens so they can revoke them 11 | """ 12 | 13 | context_object_name = "authorized_tokens" 14 | template_name = "oauth2_provider/authorized-tokens.html" 15 | model = get_access_token_model() 16 | 17 | def get_queryset(self): 18 | """ 19 | Show only user's tokens 20 | """ 21 | return super().get_queryset().select_related("application").filter(user=self.request.user) 22 | 23 | 24 | class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): 25 | """ 26 | View for revoking a specific token 27 | """ 28 | 29 | template_name = "oauth2_provider/authorized-token-delete.html" 30 | success_url = reverse_lazy("oauth2_provider:authorized-token-list") 31 | model = get_access_token_model() 32 | 33 | def get_queryset(self): 34 | return super().get_queryset().filter(user=self.request.user) 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-oauth-toolkit" 7 | dynamic = ["version"] 8 | requires-python = ">= 3.8" 9 | authors = [ 10 | {name = "Federico Frenguelli"}, 11 | {name = "Massimiliano Pippi"}, 12 | {email = "synasius@gmail.com"}, 13 | ] 14 | description = "OAuth2 Provider for Django" 15 | keywords = ["django", "oauth", "oauth2", "oauthlib"] 16 | license = {file = "LICENSE"} 17 | readme = "README.rst" 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Environment :: Web Environment", 21 | "Framework :: Django", 22 | "Framework :: Django :: 4.2", 23 | "Framework :: Django :: 5.0", 24 | "Framework :: Django :: 5.1", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: BSD License", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Topic :: Internet :: WWW/HTTP", 35 | ] 36 | dependencies = [ 37 | "django >= 4.2", 38 | "requests >= 2.13.0", 39 | "oauthlib >= 3.2.2", 40 | "jwcrypto >= 1.5.0", 41 | ] 42 | 43 | [project.optional-dependencies] 44 | dev = [ 45 | "pytest", 46 | "pytest-cov", 47 | "m2r", 48 | "sphinx-rtd-theme", 49 | ] 50 | 51 | [project.urls] 52 | Homepage = "https://django-oauth-toolkit.readthedocs.io/" 53 | Repository = "https://github.com/jazzband/django-oauth-toolkit" 54 | 55 | [tool.setuptools.dynamic] 56 | version = {attr = "oauth2_provider.__version__"} 57 | 58 | # Ref: https://github.com/codespell-project/codespell#using-a-config-file 59 | [tool.codespell] 60 | skip = '.git,package-lock.json,locale,AUTHORS,tox.ini' 61 | check-hidden = true 62 | ignore-regex = '.*pragma: codespell-ignore.*' 63 | ignore-words-list = 'assertIn' 64 | 65 | [tool.coverage.run] 66 | source = ["oauth2_provider"] 67 | omit = ["*/migrations/*"] 68 | 69 | [tool.coverage.report] 70 | show_missing = true 71 | 72 | [tool.pytest.ini_options] 73 | django_find_project = false 74 | addopts = [ 75 | "--cov=oauth2_provider", 76 | "--cov-report=", 77 | "--cov-append", 78 | "-s" 79 | ] 80 | markers = [ 81 | "oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture", 82 | "nologinrequiredmiddleware", 83 | ] 84 | 85 | [tool.ruff] 86 | line-length = 110 87 | exclude = [".tox", "build/", "dist/", "docs/", "oauth2_provider/migrations/", "tests/migrations/", "manage.py"] 88 | 89 | [tool.ruff.lint] 90 | select = ["E", "F", "I", "Q", "W"] 91 | 92 | [tool.ruff.lint.isort] 93 | lines-after-imports = 2 94 | known-first-party = ["oauth2_provider"] 95 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | 4 | class CustomApplicationAdmin(admin.ModelAdmin): 5 | list_display = ("id",) 6 | 7 | 8 | class CustomAccessTokenAdmin(admin.ModelAdmin): 9 | list_display = ("id",) 10 | 11 | 12 | class CustomGrantAdmin(admin.ModelAdmin): 13 | list_display = ("id",) 14 | 15 | 16 | class CustomIDTokenAdmin(admin.ModelAdmin): 17 | list_display = ("id",) 18 | 19 | 20 | class CustomRefreshTokenAdmin(admin.ModelAdmin): 21 | list_display = ("id",) 22 | -------------------------------------------------------------------------------- /tests/app/README.md: -------------------------------------------------------------------------------- 1 | # Test Apps 2 | 3 | These apps are for local end to end testing of DOT features. They were implemented to save maintainers the trouble of setting up 4 | local test environments. You should be able to start both and instance of the IDP and RP using the directions below, then test the 5 | functionality of the IDP using the RP. 6 | 7 | ## /tests/app/idp 8 | 9 | This is an example IDP implementation for end to end testing. There are pre-configured fixtures which will work with the sample RP. 10 | 11 | username: superuser 12 | password: password 13 | 14 | ### Development Tasks 15 | 16 | * starting up the idp 17 | 18 | ```bash 19 | cd tests/app/idp 20 | # create a virtual env if that is something you do 21 | python manage.py migrate 22 | python manage.py loaddata fixtures/seed.json 23 | python manage.py runserver 24 | # open http://localhost:8000/admin 25 | 26 | ``` 27 | 28 | * update fixtures 29 | 30 | You can update data in the IDP and then dump the data to a new seed file as follows. 31 | 32 | ``` 33 | python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json 34 | ``` 35 | 36 | ## /test/app/rp 37 | 38 | This is an example RP. It is a SPA built with Svelte. 39 | 40 | ### Development Tasks 41 | 42 | * starting the RP 43 | 44 | ```bash 45 | cd test/apps/rp 46 | npm install 47 | npm run dev 48 | # open http://localhost:5173 49 | ``` -------------------------------------------------------------------------------- /tests/app/idp/README.md: -------------------------------------------------------------------------------- 1 | # TEST IDP 2 | 3 | see ../README.md 4 | -------------------------------------------------------------------------------- /tests/app/idp/fixtures/seed.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "fields": { 5 | "password": "pbkdf2_sha256$390000$29LoVHfFRlvEOJ9clv73Wx$fx5ejfUJ+nYsnBXFf21jZvDsq4o3p5io3TrAGKAVTq4=", 6 | "last_login": "2023-11-11T17:24:19.359Z", 7 | "is_superuser": true, 8 | "username": "superuser", 9 | "first_name": "", 10 | "last_name": "", 11 | "email": "", 12 | "is_staff": true, 13 | "is_active": true, 14 | "date_joined": "2023-05-01T19:53:59.622Z", 15 | "groups": [], 16 | "user_permissions": [] 17 | } 18 | }, 19 | { 20 | "model": "oauth2_provider.application", 21 | "fields": { 22 | "client_id": "2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm", 23 | "user": null, 24 | "redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173", 25 | "post_logout_redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173", 26 | "client_type": "public", 27 | "authorization_grant_type": "authorization-code", 28 | "client_secret": "pbkdf2_sha256$600000$HEYByn6WXiQUI1D6ezTnAf$qPLekt0t3ZssnzEOvQkeOSfxx7tbs/gcC3O0CthtP2A=", 29 | "hash_client_secret": true, 30 | "name": "OIDC - Authorization Code", 31 | "skip_authorization": true, 32 | "created": "2023-05-01T20:27:46.167Z", 33 | "updated": "2023-11-11T17:23:44.643Z", 34 | "algorithm": "RS256", 35 | "allowed_origins": "http://localhost:5173\r\nhttp://127.0.0.1:5173" 36 | } 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /tests/app/idp/idp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/tests/app/idp/idp/__init__.py -------------------------------------------------------------------------------- /tests/app/idp/idp/apps.py: -------------------------------------------------------------------------------- 1 | from corsheaders.signals import check_request_enabled 2 | from django.apps import AppConfig 3 | 4 | 5 | def cors_allow_origin(sender, request, **kwargs): 6 | return ( 7 | request.path == "/o/userinfo/" 8 | or request.path == "/o/userinfo" 9 | or request.path == "/o/.well-known/openid-configuration" 10 | or request.path == "/o/.well-known/openid-configuration/" 11 | ) 12 | 13 | 14 | class IDPAppConfig(AppConfig): 15 | name = "idp" 16 | default = True 17 | 18 | def ready(self): 19 | check_request_enabled.connect(cors_allow_origin) 20 | -------------------------------------------------------------------------------- /tests/app/idp/idp/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for idp project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") 16 | 17 | application = get_asgi_application() 18 | -------------------------------------------------------------------------------- /tests/app/idp/idp/oauth.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.middleware import AuthenticationMiddleware 3 | from django.contrib.sessions.middleware import SessionMiddleware 4 | 5 | from oauth2_provider.oauth2_validators import OAuth2Validator 6 | 7 | 8 | # get_response is required for middleware, it doesn't need to do anything 9 | # the way we're using it, so we just use a lambda that returns None 10 | def get_response(): 11 | None 12 | 13 | 14 | class CustomOAuth2Validator(OAuth2Validator): 15 | def validate_silent_login(self, request) -> None: 16 | # request is an OAuthLib.common.Request and doesn't have the session 17 | # or user of the django request. We will emulate the session and auth 18 | # middleware here, since that is what the idp is using for auth. You 19 | # may need to modify this if you are using a different session 20 | # middleware or auth backend. 21 | 22 | session_cookie_name = settings.SESSION_COOKIE_NAME 23 | HTTP_COOKIE = request.headers.get("HTTP_COOKIE") 24 | COOKIES = HTTP_COOKIE.split("; ") 25 | for cookie in COOKIES: 26 | cookie_name, cookie_value = cookie.split("=") 27 | if cookie.startswith(session_cookie_name): 28 | break 29 | session_middleware = SessionMiddleware(get_response) 30 | session = session_middleware.SessionStore(cookie_value) 31 | # add session to request for compatibility with django.contrib.auth 32 | request.session = session 33 | 34 | # call the auth middleware to set request.user 35 | auth_middleware = AuthenticationMiddleware(get_response) 36 | auth_middleware.process_request(request) 37 | return request.user.is_authenticated 38 | 39 | def validate_silent_authorization(self, request) -> None: 40 | return True 41 | -------------------------------------------------------------------------------- /tests/app/idp/idp/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for idp project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import include, path 20 | 21 | 22 | urlpatterns = [ 23 | path("admin/", admin.site.urls), 24 | path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), 25 | path("accounts/", include("django.contrib.auth.urls")), 26 | ] 27 | -------------------------------------------------------------------------------- /tests/app/idp/idp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for idp project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /tests/app/idp/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /tests/app/idp/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=4.2,<=5.1 2 | django-cors-headers==3.14.0 3 | django-environ==0.11.2 4 | 5 | -e ../../../ 6 | -------------------------------------------------------------------------------- /tests/app/idp/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | 2 |

Log In

3 |
4 | {% csrf_token %} 5 | {{ form.as_p }} 6 | 7 |
-------------------------------------------------------------------------------- /tests/app/rp/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | -------------------------------------------------------------------------------- /tests/app/rp/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /tests/app/rp/.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /tests/app/rp/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /tests/app/rp/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS builder 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm ci 5 | COPY . . 6 | RUN npm run build 7 | RUN npm prune --production 8 | 9 | FROM node:18-alpine 10 | WORKDIR /app 11 | COPY --from=builder /app/build build/ 12 | COPY --from=builder /app/node_modules node_modules/ 13 | COPY package.json . 14 | EXPOSE 3000 15 | ENV NODE_ENV=production 16 | CMD [ "node", "build" ] -------------------------------------------------------------------------------- /tests/app/rp/README.md: -------------------------------------------------------------------------------- 1 | # create-svelte 2 | 3 | **Please Read ../README.md First** 4 | 5 | Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). 6 | 7 | ## Creating a project 8 | 9 | If you're seeing this, you've probably already done this step. Congrats! 10 | 11 | ```bash 12 | # create a new project in the current directory 13 | npm create svelte@latest 14 | 15 | # create a new project in my-app 16 | npm create svelte@latest my-app 17 | ``` 18 | 19 | ## Developing 20 | 21 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 22 | 23 | ```bash 24 | npm run dev 25 | 26 | # or start the server and open the app in a new browser tab 27 | npm run dev -- --open 28 | ``` 29 | 30 | ## Building 31 | 32 | To create a production version of your app: 33 | 34 | ```bash 35 | npm run build 36 | ``` 37 | 38 | You can preview the production build with `npm run preview`. 39 | 40 | > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 41 | -------------------------------------------------------------------------------- /tests/app/rp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rp", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --plugin-search-dir . --check .", 12 | "format": "prettier --plugin-search-dir . --write ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^3.1.1", 16 | "@sveltejs/adapter-node": "^5.0.1", 17 | "@sveltejs/kit": "^2.20.6", 18 | "prettier": "^3.3.2", 19 | "prettier-plugin-svelte": "^3.2.4", 20 | "svelte": "^4.2.19", 21 | "svelte-check": "^3.8.0", 22 | "tslib": "^2.4.1", 23 | "typescript": "^5.0.0", 24 | "vite": "^5.4.14" 25 | }, 26 | "type": "module", 27 | "dependencies": { 28 | "@dopry/svelte-oidc": "^1.1.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/app/rp/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /tests/app/rp/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/app/rp/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | {#if browser} 22 | 33 | Login 34 | Logout 35 | RefreshToken
36 |
isLoading: {$isLoading}
37 |
isAuthenticated: {$isAuthenticated}
38 |
authToken: {$accessToken}
39 |
idToken: {$idToken}
40 |
userInfo: {JSON.stringify($userInfo, null, 2)}
41 |
authError: {$authError}
42 |
43 | {/if} 44 | -------------------------------------------------------------------------------- /tests/app/rp/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/tests/app/rp/static/favicon.png -------------------------------------------------------------------------------- /tests/app/rp/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // build to run in containerized node.js environment 12 | adapter: adapter() 13 | } 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /tests/app/rp/tsconfig.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /tests/app/rp/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /tests/common_testing.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase as DjangoTestCase 3 | from django.test import TransactionTestCase as DjangoTransactionTestCase 4 | 5 | 6 | # The multiple database scenario setup for these tests purposefully defines 'default' as 7 | # an empty database in order to catch any assumptions in this package about database names 8 | # and in particular to ensure there is no assumption that 'default' is a valid database. 9 | # 10 | # When there are multiple databases defined, Django tests will not work unless they are 11 | # told which database(s) to work with. 12 | 13 | 14 | def retrieve_current_databases(): 15 | if len(settings.DATABASES) > 1: 16 | return [name for name in settings.DATABASES if name != "default"] 17 | else: 18 | return ["default"] 19 | 20 | 21 | class OAuth2ProviderBase: 22 | @classmethod 23 | def setUpClass(cls): 24 | cls.databases = retrieve_current_databases() 25 | super().setUpClass() 26 | 27 | 28 | class OAuth2ProviderTestCase(OAuth2ProviderBase, DjangoTestCase): 29 | """Place holder to allow overriding behaviors.""" 30 | 31 | 32 | class OAuth2ProviderTransactionTestCase(OAuth2ProviderBase, DjangoTransactionTestCase): 33 | """Place holder to allow overriding behaviors.""" 34 | -------------------------------------------------------------------------------- /tests/custom_hasher.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.hashers import PBKDF2PasswordHasher 2 | 3 | 4 | class MyPBKDF2PasswordHasher(PBKDF2PasswordHasher): 5 | """ 6 | A subclass of PBKDF2PasswordHasher that uses less iterations. 7 | """ 8 | 9 | algorithm = "fast_pbkdf2" 10 | iterations = 10000 11 | -------------------------------------------------------------------------------- /tests/db_router.py: -------------------------------------------------------------------------------- 1 | apps_in_beta = {"some_other_app", "this_one_too"} 2 | 3 | # These are bare minimum routers to fake the scenario where there is actually a 4 | # decision around where an application's models might live. 5 | 6 | 7 | class AlphaRouter: 8 | # alpha is where the core Django models are stored including user. To keep things 9 | # simple this is where the oauth2 provider models are stored as well because they 10 | # have a foreign key to User. 11 | 12 | def db_for_read(self, model, **hints): 13 | if model._meta.app_label not in apps_in_beta: 14 | return "alpha" 15 | return None 16 | 17 | def db_for_write(self, model, **hints): 18 | if model._meta.app_label not in apps_in_beta: 19 | return "alpha" 20 | return None 21 | 22 | def allow_relation(self, obj1, obj2, **hints): 23 | if obj1._state.db == "alpha" and obj2._state.db == "alpha": 24 | return True 25 | return None 26 | 27 | def allow_migrate(self, db, app_label, model_name=None, **hints): 28 | if app_label not in apps_in_beta: 29 | return db == "alpha" 30 | return None 31 | 32 | 33 | class BetaRouter: 34 | def db_for_read(self, model, **hints): 35 | if model._meta.app_label in apps_in_beta: 36 | return "beta" 37 | return None 38 | 39 | def db_for_write(self, model, **hints): 40 | if model._meta.app_label in apps_in_beta: 41 | return "beta" 42 | return None 43 | 44 | def allow_relation(self, obj1, obj2, **hints): 45 | if obj1._state.db == "beta" and obj2._state.db == "beta": 46 | return True 47 | return None 48 | 49 | def allow_migrate(self, db, app_label, model_name=None, **hints): 50 | if app_label in apps_in_beta: 51 | return db == "beta" 52 | 53 | 54 | class CrossDatabaseRouter: 55 | # alpha is where the core Django models are stored including user. To keep things 56 | # simple this is where the oauth2 provider models are stored as well because they 57 | # have a foreign key to User. 58 | def db_for_read(self, model, **hints): 59 | if model._meta.model_name == "accesstoken": 60 | return "beta" 61 | return None 62 | 63 | def db_for_write(self, model, **hints): 64 | if model._meta.model_name == "accesstoken": 65 | return "beta" 66 | return None 67 | 68 | def allow_relation(self, obj1, obj2, **hints): 69 | if obj1._state.db == "beta" and obj2._state.db == "beta": 70 | return True 71 | return None 72 | 73 | def allow_migrate(self, db, app_label, model_name=None, **hints): 74 | if model_name == "accesstoken": 75 | return db == "beta" 76 | return None 77 | -------------------------------------------------------------------------------- /tests/mig_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for CI testing if migrations have been missed. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure-9$j0^ot%41l5r(nj9hg02up-n+$59kld!0%l6pvqbd()u%z2as" # pragma: codespell-ignore 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "oauth2_provider", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": ["templates"], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "tutorial.wsgi.application" 72 | 73 | LOGIN_URL = "/admin/login/" 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": ":memory:", 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | { 94 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 95 | }, 96 | { 97 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 98 | }, 99 | { 100 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 107 | 108 | LANGUAGE_CODE = "en-us" 109 | 110 | TIME_ZONE = "UTC" 111 | 112 | USE_I18N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 119 | 120 | STATIC_URL = "static/" 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 126 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-05-27 21:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | run_before = [ 14 | ('oauth2_provider', '0001_initial'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='BaseTestApplication', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='SampleAccessToken', 26 | fields=[ 27 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name='SampleApplication', 32 | fields=[ 33 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ], 35 | ), 36 | migrations.CreateModel( 37 | name='SampleGrant', 38 | fields=[ 39 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 40 | ], 41 | ), 42 | migrations.CreateModel( 43 | name='SampleRefreshToken', 44 | fields=[ 45 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 46 | ], 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-14 20:07 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), 12 | ("tests", "0002_swapped_models"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="basetestapplication", 18 | name="post_logout_redirect_uris", 19 | field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), 20 | ), 21 | migrations.AddField( 22 | model_name="sampleapplication", 23 | name="post_logout_redirect_uris", 24 | field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-09-07 19:28 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), 12 | ('tests', '0003_basetestapplication_post_logout_redirect_uris_and_more'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='basetestapplication', 18 | name='hash_client_secret', 19 | field=models.BooleanField(default=True), 20 | ), 21 | migrations.AddField( 22 | model_name='sampleapplication', 23 | name='hash_client_secret', 24 | field=models.BooleanField(default=True), 25 | ), 26 | migrations.AlterField( 27 | model_name='sampleaccesstoken', 28 | name='id_token', 29 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='s_access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /tests/migrations/0005_basetestapplication_allowed_origins_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-09-27 22:25 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), 12 | ("tests", "0004_basetestapplication_hash_client_secret_and_more"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="basetestapplication", 18 | name="allowed_origins", 19 | field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), 20 | ), 21 | migrations.AddField( 22 | model_name="sampleapplication", 23 | name="allowed_origins", 24 | field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /tests/migrations/0006_basetestapplication_token_family.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2 on 2024-08-09 16:40 2 | 3 | from django.db import migrations, models 4 | from oauth2_provider.settings import oauth2_settings 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('tests', '0005_basetestapplication_allowed_origins_and_more'), 11 | migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL) 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='samplerefreshtoken', 17 | name='token_family', 18 | field=models.UUIDField(blank=True, editable=False, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/migrations/0007_add_localidtoken.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2024-08-08 22:47 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('tests', '0006_basetestapplication_token_family'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='LocalIDToken', 20 | fields=[ 21 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 22 | ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), 23 | ('expires', models.DateTimeField()), 24 | ('scope', models.TextField(blank=True)), 25 | ('created', models.DateTimeField(auto_now_add=True)), 26 | ('updated', models.DateTimeField(auto_now=True)), 27 | ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), 28 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_localidtoken', to=settings.AUTH_USER_MODEL)), 29 | ], 30 | options={ 31 | 'abstract': False, 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-oauth-toolkit/2f2b7f0b5172a0144e390a5555bf7b2fe23a384e/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from oauth2_provider.models import ( 4 | AbstractAccessToken, 5 | AbstractApplication, 6 | AbstractGrant, 7 | AbstractIDToken, 8 | AbstractRefreshToken, 9 | ) 10 | from oauth2_provider.settings import oauth2_settings 11 | 12 | 13 | class BaseTestApplication(AbstractApplication): 14 | allowed_schemes = models.TextField(blank=True) 15 | 16 | def get_allowed_schemes(self): 17 | if self.allowed_schemes: 18 | return self.allowed_schemes.split() 19 | return super().get_allowed_schemes() 20 | 21 | 22 | class SampleApplication(AbstractApplication): 23 | custom_field = models.CharField(max_length=255) 24 | 25 | 26 | class SampleAccessToken(AbstractAccessToken): 27 | custom_field = models.CharField(max_length=255) 28 | source_refresh_token = models.OneToOneField( 29 | # unique=True implied by the OneToOneField 30 | oauth2_settings.REFRESH_TOKEN_MODEL, 31 | on_delete=models.SET_NULL, 32 | blank=True, 33 | null=True, 34 | related_name="s_refreshed_access_token", 35 | ) 36 | id_token = models.OneToOneField( 37 | oauth2_settings.ID_TOKEN_MODEL, 38 | on_delete=models.CASCADE, 39 | blank=True, 40 | null=True, 41 | related_name="s_access_token", 42 | ) 43 | 44 | 45 | class SampleRefreshToken(AbstractRefreshToken): 46 | custom_field = models.CharField(max_length=255) 47 | access_token = models.OneToOneField( 48 | oauth2_settings.ACCESS_TOKEN_MODEL, 49 | on_delete=models.SET_NULL, 50 | blank=True, 51 | null=True, 52 | related_name="s_refresh_token", 53 | ) 54 | 55 | 56 | class SampleGrant(AbstractGrant): 57 | custom_field = models.CharField(max_length=255) 58 | 59 | 60 | class LocalIDToken(AbstractIDToken): 61 | """Exists to be improperly configured for multiple databases.""" 62 | 63 | # The other token types will be in 'alpha' database. 64 | -------------------------------------------------------------------------------- /tests/multi_db_settings.py: -------------------------------------------------------------------------------- 1 | # Import the test settings and then override DATABASES. 2 | 3 | from .settings import * # noqa: F401, F403 4 | 5 | 6 | DATABASES = { 7 | "alpha": { 8 | "ENGINE": "django.db.backends.sqlite3", 9 | "NAME": ":memory:", 10 | }, 11 | "beta": { 12 | "ENGINE": "django.db.backends.sqlite3", 13 | "NAME": ":memory:", 14 | }, 15 | # As https://docs.djangoproject.com/en/4.2/topics/db/multi-db/#defining-your-databases 16 | # indicates, it is ok to have no default database. 17 | "default": {}, 18 | } 19 | DATABASE_ROUTERS = ["tests.db_router.AlphaRouter", "tests.db_router.BetaRouter"] 20 | -------------------------------------------------------------------------------- /tests/multi_db_settings_invalid_token_configuration.py: -------------------------------------------------------------------------------- 1 | from .multi_db_settings import * # noqa: F401, F403 2 | 3 | 4 | OAUTH2_PROVIDER = { 5 | # The other two tokens will be in alpha. This will cause a failure when the 6 | # app's ready method is called. 7 | "ID_TOKEN_MODEL": "tests.LocalIDToken", 8 | } 9 | -------------------------------------------------------------------------------- /tests/presets.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from django.conf import settings 4 | 5 | 6 | # A set of OAUTH2_PROVIDER settings dicts that can be used in tests 7 | 8 | DEFAULT_SCOPES_RW = {"DEFAULT_SCOPES": ["read", "write"]} 9 | DEFAULT_SCOPES_RO = {"DEFAULT_SCOPES": ["read"]} 10 | OIDC_SETTINGS_RW = { 11 | "OIDC_ENABLED": True, 12 | "OIDC_ISS_ENDPOINT": "http://localhost/o", 13 | "OIDC_USERINFO_ENDPOINT": "http://localhost/o/userinfo/", 14 | "OIDC_RSA_PRIVATE_KEY": settings.OIDC_RSA_PRIVATE_KEY, 15 | "OIDC_RSA_PRIVATE_KEYS_INACTIVE": settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, 16 | "SCOPES": { 17 | "read": "Reading scope", 18 | "write": "Writing scope", 19 | "openid": "OpenID connect", 20 | }, 21 | "DEFAULT_SCOPES": ["read", "write"], 22 | "PKCE_REQUIRED": False, 23 | "REFRESH_TOKEN_EXPIRE_SECONDS": 3600, 24 | } 25 | OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) 26 | OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] 27 | OIDC_SETTINGS_EMAIL_SCOPE = deepcopy(OIDC_SETTINGS_RW) 28 | OIDC_SETTINGS_EMAIL_SCOPE["SCOPES"].update({"email": "return email address"}) 29 | OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW) 30 | del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"] 31 | OIDC_SETTINGS_RP_LOGOUT = deepcopy(OIDC_SETTINGS_RW) 32 | OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ENABLED"] = True 33 | OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT"] = False 34 | OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI = deepcopy(OIDC_SETTINGS_RP_LOGOUT) 35 | OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI["OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS"] = True 36 | OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED = deepcopy(OIDC_SETTINGS_RP_LOGOUT) 37 | OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED["OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS"] = False 38 | OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS = deepcopy(OIDC_SETTINGS_RP_LOGOUT) 39 | OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS["OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS"] = False 40 | REST_FRAMEWORK_SCOPES = { 41 | "SCOPES": { 42 | "read": "Read scope", 43 | "write": "Write scope", 44 | "scope1": "Scope 1", 45 | "scope2": "Scope 2", 46 | "resource1": "Resource 1", 47 | }, 48 | } 49 | INTROSPECTION_SETTINGS = { 50 | "SCOPES": { 51 | "read": "Read scope", 52 | "write": "Write scope", 53 | "introspection": "Introspection scope", 54 | "dolphin": "eek eek eek scope", 55 | }, 56 | "RESOURCE_SERVER_INTROSPECTION_URL": "http://example.org/introspection", 57 | "READ_SCOPE": "read", 58 | "WRITE_SCOPE": "write", 59 | } 60 | 61 | ALLOWED_SCHEMES_DEFAULT = { 62 | "ALLOWED_SCHEMES": ["https"], 63 | } 64 | 65 | ALLOWED_SCHEMES_HTTP = { 66 | "ALLOWED_SCHEMES": ["https", "http"], 67 | } 68 | -------------------------------------------------------------------------------- /tests/settings_swapped.py: -------------------------------------------------------------------------------- 1 | from .settings import * # noqa 2 | 3 | 4 | OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "tests.SampleAccessToken" 5 | OAUTH2_PROVIDER_APPLICATION_MODEL = "tests.SampleApplication" 6 | OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "tests.SampleRefreshToken" 7 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | import pytest 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.hashers import check_password 6 | from django.core.management import call_command 7 | from django.core.management.base import CommandError 8 | 9 | from oauth2_provider.models import get_application_model 10 | 11 | from . import presets 12 | from .common_testing import OAuth2ProviderTestCase as TestCase 13 | 14 | 15 | Application = get_application_model() 16 | 17 | 18 | class CreateApplicationTest(TestCase): 19 | def test_command_creates_application(self): 20 | output = StringIO() 21 | self.assertEqual(Application.objects.count(), 0) 22 | call_command( 23 | "createapplication", 24 | "confidential", 25 | "authorization-code", 26 | "--redirect-uris=http://example.com http://example2.com", 27 | stdout=output, 28 | ) 29 | self.assertEqual(Application.objects.count(), 1) 30 | self.assertIn("created successfully", output.getvalue()) 31 | 32 | def test_missing_required_args(self): 33 | self.assertEqual(Application.objects.count(), 0) 34 | with self.assertRaises(CommandError) as ctx: 35 | call_command( 36 | "createapplication", 37 | "--redirect-uris=http://example.com http://example2.com", 38 | ) 39 | 40 | self.assertIn("client_type", ctx.exception.args[0]) 41 | self.assertIn("authorization_grant_type", ctx.exception.args[0]) 42 | self.assertEqual(Application.objects.count(), 0) 43 | 44 | def test_command_creates_application_with_skipped_auth(self): 45 | self.assertEqual(Application.objects.count(), 0) 46 | call_command( 47 | "createapplication", 48 | "confidential", 49 | "authorization-code", 50 | "--redirect-uris=http://example.com http://example2.com", 51 | "--skip-authorization", 52 | ) 53 | app = Application.objects.get() 54 | 55 | self.assertTrue(app.skip_authorization) 56 | 57 | def test_application_created_normally_with_no_skipped_auth(self): 58 | call_command( 59 | "createapplication", 60 | "confidential", 61 | "authorization-code", 62 | "--redirect-uris=http://example.com http://example2.com", 63 | ) 64 | app = Application.objects.get() 65 | 66 | self.assertFalse(app.skip_authorization) 67 | 68 | def test_application_created_with_name(self): 69 | call_command( 70 | "createapplication", 71 | "confidential", 72 | "authorization-code", 73 | "--redirect-uris=http://example.com http://example2.com", 74 | "--name=TEST", 75 | ) 76 | app = Application.objects.get() 77 | 78 | self.assertEqual(app.name, "TEST") 79 | 80 | def test_application_created_with_client_secret(self): 81 | call_command( 82 | "createapplication", 83 | "confidential", 84 | "authorization-code", 85 | "--redirect-uris=http://example.com http://example2.com", 86 | "--client-secret=SECRET", 87 | ) 88 | app = Application.objects.get() 89 | 90 | self.assertTrue(check_password("SECRET", app.client_secret)) 91 | 92 | def test_application_created_with_client_id(self): 93 | call_command( 94 | "createapplication", 95 | "confidential", 96 | "authorization-code", 97 | "--redirect-uris=http://example.com http://example2.com", 98 | "--client-id=someId", 99 | ) 100 | app = Application.objects.get() 101 | 102 | self.assertEqual(app.client_id, "someId") 103 | 104 | def test_application_created_with_user(self): 105 | User = get_user_model() 106 | user = User.objects.create() 107 | call_command( 108 | "createapplication", 109 | "confidential", 110 | "authorization-code", 111 | "--redirect-uris=http://example.com http://example2.com", 112 | "--user=%s" % user.pk, 113 | ) 114 | app = Application.objects.get() 115 | 116 | self.assertEqual(app.user, user) 117 | 118 | @pytest.mark.usefixtures("oauth2_settings") 119 | @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) 120 | def test_application_created_with_algorithm(self): 121 | call_command( 122 | "createapplication", 123 | "confidential", 124 | "authorization-code", 125 | "--redirect-uris=http://example.com http://example2.com", 126 | "--algorithm=RS256", 127 | ) 128 | app = Application.objects.get() 129 | 130 | self.assertEqual(app.algorithm, "RS256") 131 | 132 | def test_validation_failed_message(self): 133 | import django 134 | 135 | output = StringIO() 136 | call_command( 137 | "createapplication", 138 | "confidential", 139 | "authorization-code", 140 | "--redirect-uris=http://example.com http://example2.com", 141 | "--user=783", 142 | stdout=output, 143 | ) 144 | 145 | output_str = output.getvalue() 146 | self.assertIn("user", output_str) 147 | self.assertIn("783", output_str) 148 | if django.VERSION < (5, 2): 149 | self.assertIn("does not exist", output_str) 150 | else: 151 | self.assertIn("is not a valid choice", output_str) 152 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.test import RequestFactory 5 | from django.utils import timezone 6 | 7 | from oauth2_provider.decorators import protected_resource, rw_protected_resource 8 | from oauth2_provider.models import get_access_token_model, get_application_model 9 | 10 | from .common_testing import OAuth2ProviderTestCase as TestCase 11 | 12 | 13 | Application = get_application_model() 14 | AccessToken = get_access_token_model() 15 | UserModel = get_user_model() 16 | 17 | 18 | class TestProtectedResourceDecorator(TestCase): 19 | request_factory = RequestFactory() 20 | 21 | @classmethod 22 | def setUpTestData(cls): 23 | cls.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") 24 | cls.application = Application.objects.create( 25 | name="test_client_credentials_app", 26 | user=cls.user, 27 | client_type=Application.CLIENT_PUBLIC, 28 | authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, 29 | ) 30 | 31 | cls.access_token = AccessToken.objects.create( 32 | user=cls.user, 33 | scope="read write", 34 | expires=timezone.now() + timedelta(seconds=300), 35 | token="secret-access-token-key", 36 | application=cls.application, 37 | ) 38 | 39 | def test_access_denied(self): 40 | @protected_resource() 41 | def view(request, *args, **kwargs): 42 | return "protected contents" 43 | 44 | request = self.request_factory.get("/fake-resource") 45 | response = view(request) 46 | self.assertEqual(response.status_code, 403) 47 | 48 | def test_access_allowed(self): 49 | @protected_resource() 50 | def view(request, *args, **kwargs): 51 | return "protected contents" 52 | 53 | @protected_resource(scopes=["can_touch_this"]) 54 | def scoped_view(request, *args, **kwargs): 55 | return "moar protected contents" 56 | 57 | auth_headers = { 58 | "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, 59 | } 60 | request = self.request_factory.get("/fake-resource", **auth_headers) 61 | response = view(request) 62 | self.assertEqual(response, "protected contents") 63 | 64 | # now with scopes 65 | self.access_token.scope = "can_touch_this" 66 | self.access_token.save() 67 | auth_headers = { 68 | "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, 69 | } 70 | request = self.request_factory.get("/fake-resource", **auth_headers) 71 | response = scoped_view(request) 72 | self.assertEqual(response, "moar protected contents") 73 | 74 | def test_rw_protected(self): 75 | self.access_token.scope = "exotic_scope write" 76 | self.access_token.save() 77 | auth_headers = { 78 | "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, 79 | } 80 | 81 | @rw_protected_resource(scopes=["exotic_scope"]) 82 | def scoped_view(request, *args, **kwargs): 83 | return "other protected contents" 84 | 85 | request = self.request_factory.post("/fake-resource", **auth_headers) 86 | response = scoped_view(request) 87 | self.assertEqual(response, "other protected contents") 88 | 89 | request = self.request_factory.get("/fake-resource", **auth_headers) 90 | response = scoped_view(request) 91 | self.assertEqual(response.status_code, 403) 92 | -------------------------------------------------------------------------------- /tests/test_django_checks.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.core.management.base import SystemCheckError 3 | from django.test import override_settings 4 | 5 | from .common_testing import OAuth2ProviderTestCase as TestCase 6 | 7 | 8 | class DjangoChecksTestCase(TestCase): 9 | def test_checks_pass(self): 10 | call_command("check") 11 | 12 | # CrossDatabaseRouter claims AccessToken is in beta while everything else is in alpha. 13 | # This will cause the database checks to fail. 14 | @override_settings( 15 | DATABASE_ROUTERS=["tests.db_router.CrossDatabaseRouter", "tests.db_router.AlphaRouter"] 16 | ) 17 | def test_checks_fail_when_router_crosses_databases(self): 18 | message = "The token models are expected to be stored in the same database." 19 | with self.assertRaisesMessage(SystemCheckError, message): 20 | call_command("check") 21 | -------------------------------------------------------------------------------- /tests/test_generator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from oauth2_provider.generators import BaseHashGenerator, generate_client_id, generate_client_secret 4 | 5 | from .common_testing import OAuth2ProviderTestCase as TestCase 6 | 7 | 8 | class MockHashGenerator(BaseHashGenerator): 9 | def hash(self): 10 | return 42 11 | 12 | 13 | @pytest.mark.usefixtures("oauth2_settings") 14 | class TestGenerators(TestCase): 15 | def test_generate_client_id(self): 16 | g = self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS() 17 | self.assertEqual(len(g.hash()), 40) 18 | 19 | self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS = MockHashGenerator 20 | self.assertEqual(generate_client_id(), 42) 21 | 22 | def test_generate_secret_id(self): 23 | g = self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() 24 | self.assertEqual(len(g.hash()), 128) 25 | 26 | self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = MockHashGenerator 27 | self.assertEqual(generate_client_secret(), 42) 28 | 29 | def test_basegen_misuse(self): 30 | g = BaseHashGenerator() 31 | self.assertRaises(NotImplementedError, g.hash) 32 | -------------------------------------------------------------------------------- /tests/test_password.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from django.contrib.auth import get_user_model 5 | from django.test import RequestFactory 6 | from django.urls import reverse 7 | 8 | from oauth2_provider.models import get_application_model 9 | from oauth2_provider.views import ProtectedResourceView 10 | 11 | from .common_testing import OAuth2ProviderTestCase as TestCase 12 | from .utils import get_basic_auth_header 13 | 14 | 15 | Application = get_application_model() 16 | UserModel = get_user_model() 17 | 18 | CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" 19 | 20 | 21 | # mocking a protected resource view 22 | class ResourceView(ProtectedResourceView): 23 | def get(self, request, *args, **kwargs): 24 | return "This is a protected resource" 25 | 26 | 27 | @pytest.mark.usefixtures("oauth2_settings") 28 | class BaseTest(TestCase): 29 | factory = RequestFactory() 30 | 31 | @classmethod 32 | def setUpTestData(cls): 33 | cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") 34 | cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") 35 | 36 | cls.application = Application.objects.create( 37 | name="Test Password Application", 38 | user=cls.dev_user, 39 | client_type=Application.CLIENT_PUBLIC, 40 | authorization_grant_type=Application.GRANT_PASSWORD, 41 | client_secret=CLEARTEXT_SECRET, 42 | ) 43 | 44 | 45 | class TestPasswordTokenView(BaseTest): 46 | def test_get_token(self): 47 | """ 48 | Request an access token using Resource Owner Password Flow 49 | """ 50 | token_request_data = { 51 | "grant_type": "password", 52 | "username": "test_user", 53 | "password": "123456", 54 | } 55 | auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) 56 | 57 | response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) 58 | self.assertEqual(response.status_code, 200) 59 | 60 | content = json.loads(response.content.decode("utf-8")) 61 | self.assertEqual(content["token_type"], "Bearer") 62 | self.assertEqual(set(content["scope"].split()), {"read", "write"}) 63 | self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) 64 | 65 | def test_bad_credentials(self): 66 | """ 67 | Request an access token using Resource Owner Password Flow 68 | """ 69 | token_request_data = { 70 | "grant_type": "password", 71 | "username": "test_user", 72 | "password": "NOT_MY_PASS", 73 | } 74 | auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) 75 | 76 | response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) 77 | self.assertEqual(response.status_code, 400) 78 | 79 | 80 | class TestPasswordProtectedResource(BaseTest): 81 | def test_password_resource_access_allowed(self): 82 | token_request_data = { 83 | "grant_type": "password", 84 | "username": "test_user", 85 | "password": "123456", 86 | } 87 | auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) 88 | 89 | response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) 90 | content = json.loads(response.content.decode("utf-8")) 91 | access_token = content["access_token"] 92 | 93 | # use token to access the resource 94 | auth_headers = { 95 | "HTTP_AUTHORIZATION": "Bearer " + access_token, 96 | } 97 | request = self.factory.get("/fake-resource", **auth_headers) 98 | request.user = self.test_user 99 | 100 | view = ResourceView.as_view() 101 | response = view(request) 102 | self.assertEqual(response, "This is a protected resource") 103 | -------------------------------------------------------------------------------- /tests/test_scopes_backend.py: -------------------------------------------------------------------------------- 1 | from oauth2_provider.scopes import SettingsScopes 2 | 3 | 4 | def test_settings_scopes_get_available_scopes(): 5 | scopes = SettingsScopes() 6 | assert set(scopes.get_available_scopes()) == {"read", "write"} 7 | 8 | 9 | def test_settings_scopes_get_default_scopes(): 10 | scopes = SettingsScopes() 11 | assert set(scopes.get_default_scopes()) == {"read", "write"} 12 | -------------------------------------------------------------------------------- /tests/test_ui_locales.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase, override_settings 3 | from django.urls import reverse 4 | 5 | from oauth2_provider.models import get_application_model 6 | 7 | 8 | UserModel = get_user_model() 9 | Application = get_application_model() 10 | 11 | 12 | @override_settings( 13 | OAUTH2_PROVIDER={ 14 | "OIDC_ENABLED": True, 15 | "PKCE_REQUIRED": False, 16 | "SCOPES": { 17 | "openid": "OpenID connect", 18 | }, 19 | } 20 | ) 21 | class TestUILocalesParam(TestCase): 22 | @classmethod 23 | def setUpTestData(cls): 24 | cls.application = Application.objects.create( 25 | name="Test Application", 26 | client_id="test", 27 | redirect_uris="https://www.example.com/", 28 | client_type=Application.CLIENT_PUBLIC, 29 | authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, 30 | ) 31 | cls.trusted_application = Application.objects.create( 32 | name="Trusted Application", 33 | client_id="trusted", 34 | redirect_uris="https://www.example.com/", 35 | client_type=Application.CLIENT_PUBLIC, 36 | authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, 37 | skip_authorization=True, 38 | ) 39 | cls.user = UserModel.objects.create_user("test_user") 40 | cls.url = reverse("oauth2_provider:authorize") 41 | 42 | def setUp(self): 43 | self.client.force_login(self.user) 44 | 45 | def test_application_ui_locales_param(self): 46 | response = self.client.get( 47 | f"{self.url}?response_type=code&client_id=test&scope=openid&ui_locales=de", 48 | ) 49 | self.assertEqual(response.status_code, 200) 50 | self.assertTemplateUsed(response, "oauth2_provider/authorize.html") 51 | 52 | def test_trusted_application_ui_locales_param(self): 53 | response = self.client.get( 54 | f"{self.url}?response_type=code&client_id=trusted&scope=openid&ui_locales=de", 55 | ) 56 | self.assertEqual(response.status_code, 302) 57 | self.assertRegex(response.url, r"https://www\.example\.com/\?code=[a-zA-Z0-9]+") 58 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from oauth2_provider import utils 2 | 3 | 4 | def test_jwk_from_pem_caches_jwk(): 5 | a_tiny_rsa_key = """-----BEGIN RSA PRIVATE KEY----- 6 | MGQCAQACEQCxqYaL6GtPooVMhVwcZrCfAgMBAAECECyNmdsuHvMqIEl9/Fex27kC 7 | CQDlc0deuSVrtQIJAMY4MTw2eCeDAgkA5VzfMykQ5yECCQCgkF4Zl0nHPwIJALPv 8 | +IAFUPv3 9 | -----END RSA PRIVATE KEY-----""" 10 | 11 | # For the same private key we expect the same object to be returned 12 | 13 | jwk1 = utils.jwk_from_pem(a_tiny_rsa_key) 14 | jwk2 = utils.jwk_from_pem(a_tiny_rsa_key) 15 | 16 | assert jwk1 is jwk2 17 | 18 | a_different_tiny_rsa_key = """-----BEGIN RSA PRIVATE KEY----- 19 | MGMCAQACEQCvyNNNw4J201yzFVogcfgnAgMBAAECEE3oXe5bNlle+xU4EVHTUIEC 20 | CQDpSvwIvDMSIQIJAMDk47DzG9FHAghtvg1TWpy3oQIJAL6NHlS+RBufAgkA6QLA 21 | 2GK4aDc= 22 | -----END RSA PRIVATE KEY-----""" 23 | 24 | # But for a different key, a different object 25 | jwk3 = utils.jwk_from_pem(a_different_tiny_rsa_key) 26 | 27 | assert jwk3 is not jwk1 28 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | from oauth2_provider import urls as oauth2_urls 5 | 6 | 7 | admin.autodiscover() 8 | 9 | 10 | urlpatterns = [ 11 | path("o/", include(oauth2_urls)), 12 | path("admin/", admin.site.urls), 13 | ] 14 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from unittest import mock 3 | 4 | 5 | def get_basic_auth_header(user, password): 6 | """ 7 | Return a dict containing the correct headers to set to make HTTP Basic 8 | Auth request 9 | """ 10 | user_pass = "{0}:{1}".format(user, password) 11 | auth_string = base64.b64encode(user_pass.encode("utf-8")) 12 | auth_headers = { 13 | "HTTP_AUTHORIZATION": "Basic " + auth_string.decode("utf-8"), 14 | } 15 | 16 | return auth_headers 17 | 18 | 19 | def spy_on(meth): 20 | """ 21 | Util function to add a spy onto a method of a class. 22 | """ 23 | spy = mock.MagicMock() 24 | 25 | def wrapper(self, *args, **kwargs): 26 | spy(self, *args, **kwargs) 27 | return_value = meth(self, *args, **kwargs) 28 | spy.returned = return_value 29 | return return_value 30 | 31 | wrapper.spy = spy 32 | return wrapper 33 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | migrations, 4 | migrate_swapped, 5 | docs, 6 | lint, 7 | sphinxlint, 8 | py{38,39,310,311,312}-dj42, 9 | py{310,311,312}-dj50, 10 | py{310,311,312}-dj51, 11 | py{310,311,312}-djmain, 12 | py39-multi-db-dj-42 13 | 14 | [gh-actions] 15 | python = 16 | 3.8: py38, docs, lint, migrations, migrate_swapped, sphinxlint 17 | 3.9: py39 18 | 3.10: py310 19 | 3.11: py311 20 | 3.12: py312 21 | 22 | [gh-actions:env] 23 | DJANGO = 24 | 4.2: dj42 25 | 5.0: dj50 26 | 5.1: dj51 27 | main: djmain 28 | 29 | [testenv] 30 | commands = 31 | pytest {posargs} 32 | coverage report 33 | coverage xml 34 | setenv = 35 | DJANGO_SETTINGS_MODULE = tests.settings 36 | PYTHONPATH = {toxinidir} 37 | PYTHONWARNINGS = all 38 | deps = 39 | dj42: Django>=4.2,<4.3 40 | dj50: Django>=5.0,<5.1 41 | dj51: Django>=5.1,<5.2 42 | djmain: https://github.com/django/django/archive/main.tar.gz 43 | djangorestframework 44 | oauthlib>=3.2.2 45 | jwcrypto 46 | coverage 47 | pytest 48 | pytest-cov 49 | pytest-django 50 | pytest-xdist 51 | pytest-mock 52 | requests 53 | pytz; python_version < '3.9' 54 | passenv = 55 | PYTEST_ADDOPTS 56 | 57 | [testenv:py{310,311,312}-djmain] 58 | ignore_errors = true 59 | ignore_outcome = true 60 | 61 | [testenv:sphinxlint] 62 | deps = sphinx-lint 63 | skip_install = True 64 | commands = 65 | sphinx-lint docs/ 66 | 67 | [testenv:{docs,livedocs}] 68 | basepython = python3.8 69 | changedir = docs 70 | allowlist_externals = make 71 | commands = 72 | docs: make html 73 | livedocs: make livehtml 74 | deps = 75 | Jinja2<3.1 76 | sphinx<3 77 | oauthlib>=3.2.2 78 | m2r>=0.2.1 79 | mistune<2 80 | sphinx-rtd-theme 81 | livedocs: sphinx-autobuild 82 | jwcrypto 83 | django 84 | 85 | [testenv:lint] 86 | basepython = python3.8 87 | deps = ruff>=0.6 88 | skip_install = True 89 | commands = 90 | ruff format --check 91 | ruff check 92 | 93 | [testenv:migrations] 94 | setenv = 95 | DJANGO_SETTINGS_MODULE = tests.mig_settings 96 | PYTHONPATH = {toxinidir} 97 | PYTHONWARNINGS = all 98 | commands = django-admin makemigrations --dry-run --check 99 | 100 | [testenv:py39-multi-db-dj42] 101 | setenv = 102 | DJANGO_SETTINGS_MODULE = tests.multi_db_settings 103 | PYTHONPATH = {toxinidir} 104 | PYTHONWARNINGS = all 105 | 106 | [testenv:migrate_swapped] 107 | setenv = 108 | DJANGO_SETTINGS_MODULE = tests.settings_swapped 109 | PYTHONPATH = {toxinidir} 110 | PYTHONWARNINGS = all 111 | commands = 112 | django-admin migrate 113 | 114 | [testenv:build] 115 | deps = 116 | build 117 | twine 118 | allowlist_externals = rm 119 | commands = 120 | rm -rf dist 121 | python -m build 122 | twine check dist/* 123 | --------------------------------------------------------------------------------