├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── VERSION ├── docs ├── docs │ ├── contributing.md │ ├── developing.md │ ├── images │ ├── index.md │ ├── overrides │ │ └── main.css │ ├── release-notes.md │ └── user_guide │ │ ├── classes.md │ │ └── examples.md ├── images │ └── logo-circle.svg ├── mkdocs.yml └── src │ └── tmp.py ├── fastapi_jwt ├── __init__.py ├── jwt.py ├── jwt_backends │ ├── __init__.py │ ├── abstract_backend.py │ ├── authlib_backend.py │ └── python_jose_backend.py └── py.typed ├── pyproject.toml ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── mock_datetime_utils.py ├── test_security_jwt_bearer.py ├── test_security_jwt_bearer_optional.py ├── test_security_jwt_cookie.py ├── test_security_jwt_cookie_optional.py ├── test_security_jwt_general.py ├── test_security_jwt_general_optional.py ├── test_security_jwt_multiple_places.py └── test_security_jwt_set_cookie.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" # Workflow files stored in the default location of `.github/workflows` 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | - dependency-name: "*" 15 | update-types: ["version-update:semver-patch"] 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | 9 | jobs: 10 | update-version-and-changelog: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | ref: main 16 | - name: Update version file 17 | run: | 18 | cat VERSION 19 | echo "VERSION ${{ github.ref_name }}" 20 | echo -n "${{ github.ref_name }}" > VERSION 21 | - name: Update changelog file 22 | run: | 23 | echo "CHANGELOG" 24 | - name: Commit updated files 25 | run: | 26 | git config --global user.name 'github-actions' 27 | git config --global user.email 'github-actions@users.noreply.github.com' 28 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} 29 | git add VERSION CHANGELOG.md 30 | git commit -m "Auto version and changelog update [${{ github.ref_name }}]" 31 | git push origin 32 | - name: Tag new commit 33 | run: | 34 | git tag --force ${{ github.ref_name }} 35 | git push origin ${{ github.ref_name }} --force 36 | 37 | update-release-github: 38 | needs: update-version-and-changelog 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | with: 43 | ref: ${{ github.ref_name }} 44 | - name: Update Release description 45 | run: | 46 | echo "VERSION" 47 | echo "CHANGELOG" 48 | 49 | release-python-package: 50 | needs: update-version-and-changelog 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | with: 55 | ref: ${{ github.ref_name }} 56 | - name: Setup Python 3.11 57 | uses: actions/setup-python@v5 58 | with: 59 | python-version: 3.11 60 | - name: Build python package 61 | run: | 62 | python -m pip install wheel twine 63 | python -m pip wheel . --no-deps --wheel-dir dist 64 | twine upload -u __token__ -p ${{ secrets.PYPI_TOKEN }} dist/* 65 | 66 | release-github-pages: 67 | needs: update-version-and-changelog 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | with: 72 | ref: ${{ github.ref_name }} 73 | - name: Setup Python 3.11 74 | uses: actions/setup-python@v5 75 | with: 76 | python-version: 3.11 77 | - uses: actions/cache@v4 78 | with: 79 | path: ${{ env.pythonLocation }} 80 | key: ${{ runner.os }}-python-3.11-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml', 'setup.cfg') }}-docs 81 | restore-keys: | 82 | ${{ runner.os }}-python-3.11- 83 | ${{ runner.os }}-python- 84 | ${{ runner.os }}- 85 | - name: Install dependencies 86 | run: python -m pip install -e .[docs] 87 | - name: Build and publish docs 88 | run: | 89 | git fetch --all 90 | # lazydocs 91 | python -m mkdocs build --config-file docs/mkdocs.yml 92 | python -m mkdocs gh-deploy --config-file docs/mkdocs.yml --force 93 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | 10 | jobs: 11 | lint-python: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.12' 18 | cache: 'pip' # caching pip dependencies 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install -e .[test] 22 | - name: Run isort 23 | run: python -m isort fastapi_jwt --check 24 | - name: Run linter 25 | run: | 26 | # can remove flake8 after https://github.com/astral-sh/ruff/issues/2402 27 | python -m flake8 fastapi_jwt tests 28 | python -m ruff check fastapi_jwt tests 29 | - name: Run mypy 30 | run: python -m mypy fastapi_jwt 31 | 32 | test-python: 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 37 | fail-fast: false 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Set up Python ${{ matrix.python-version }} 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | cache: 'pip' # caching pip dependencies 45 | - name: Install dependencies 46 | run: | 47 | python -m pip install wheel 48 | python -m pip install -e .[test] 49 | - name: Test itself 50 | run: python -m pytest --cov-report=xml 51 | - name: Upload coverage 52 | uses: codecov/codecov-action@v5 53 | with: 54 | files: coverage.xml 55 | fail_ci_if_error: true # optional (default = false) 56 | verbose: true # optional (default = false) 57 | token: ${{ secrets.CODECOV_TOKEN }} # required 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Latest Changes 4 | 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to fastapi-jwt 2 | 3 | First, thanks for taking the time to contribute! 😍 4 | It's highly welcomed, and it can help the project to develop and become more usefully and suitable for everyone. 5 | 6 | ## Styleguides 7 | 8 | ### Git Commit Messages 9 | 10 | This project uses light version of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). 11 | tl;dr 12 | 13 | * Use the present tense ("Add feature" not "Added feature") 14 | * Use the imperative mood ("Move cursor to..." not "Moves cursor to...") 15 | * Limit the first line to 72 characters or less 16 | * Reference issues and pull requests in commit body 17 | * When only changing documentation, include [ci skip] in the commit title 18 | * Consider starting the commit message with an applicable tag: 19 | * `fix` - small bug fix 20 | * `docs` - docs changes 21 | * `feat` - a new feature 22 | * `chore` - changes that do not relate to a fix or feature and don't modify src or test files (for example updating dependencies) 23 | * `refactor` - code refactor that neither fixes a bug nor adds a feature 24 | * `style` - changes that do not affect the meaning of the code 25 | * `perf` - changes that improve performance 26 | * `test` - including new or correcting previous tests 27 | * `ci` - continuous integration related 28 | 29 | For example 30 | 31 | Good: 32 | * `feat: create new api endpoint for student scores reporting` 33 | * `perf: improve performance with lazy load implementation for images` 34 | * `chore: update flask dependency to 2.1 version` 35 | 36 | Bad: 37 | * `some fixes` 38 | * `oops` 39 | * `fixed bug on landing page` 40 | * `style changes and update` 41 | 42 | ### Python styleguide 43 | 44 | All python code should follow [PEP8](https://www.python.org/dev/peps/pep-0008/) and be typed. 45 | The code linted with `flake8` and `isort`, as well as type checked with `mypy`. 46 | 47 | TBA 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Konstantin Chernyshev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-jwt 2 | 3 | [![Test](https://github.com/k4black/fastapi-jwt/actions/workflows/test.yml/badge.svg)](https://github.com/k4black/fastapi-jwt/actions/workflows/test.yml) 4 | [![Publish](https://github.com/k4black/fastapi-jwt/actions/workflows/publish.yml/badge.svg)](https://github.com/k4black/fastapi-jwt/actions/workflows/publish.yml) 5 | [![codecov](https://codecov.io/gh/k4black/fastapi-jwt/branch/master/graph/badge.svg?token=3F9J850FX2)](https://codecov.io/gh/k4black/fastapi-jwt) 6 | [![pypi](https://img.shields.io/pypi/v/fastapi-jwt)](https://pypi.org/project/fastapi-jwt/) 7 | 8 | FastAPI native extension, easy and simple JWT auth 9 | 10 | --- 11 | 12 | 13 | **Documentation:** [k4black.github.io/fastapi-jwt](https://k4black.github.io/fastapi-jwt/) 14 | **Source Code:** [github.com/k4black/fastapi-jwt](https://github.com/k4black/fastapi-jwt/) 15 | 16 | 17 | ## Features 18 | * OpenAPI schema generation 19 | * Native integration with FastAPI 20 | * Access/Refresh JWT 21 | * JTI 22 | * Cookie setting 23 | 24 | 25 | ## Installation 26 | You can access package [fastapi-jwt in pypi](https://pypi.org/project/fastapi-jwt/) 27 | ```shell 28 | pip install fastapi-jwt[authlib] 29 | # or 30 | pip install fastapi-jwt[python_jose] 31 | ``` 32 | 33 | The fastapi-jwt will choose the backend automatically if library is installed with the following priority: 34 | 1. authlib 35 | 2. python_jose (deprecated) 36 | 37 | ## Usage 38 | This library made in fastapi style, so it can be used as standard security features 39 | 40 | ```python 41 | from fastapi import FastAPI, Security, Response 42 | from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearer 43 | 44 | 45 | app = FastAPI() 46 | access_security = JwtAccessBearer(secret_key="secret_key", auto_error=True) 47 | 48 | 49 | @app.post("/auth") 50 | def auth(): 51 | subject = {"username": "username", "role": "user"} 52 | return {"access_token": access_security.create_access_token(subject=subject)} 53 | 54 | @app.post("/auth_cookie") 55 | def auth(response: Response): 56 | subject = {"username": "username", "role": "user"} 57 | access_token = access_security.create_access_token(subject=subject) 58 | access_security.set_access_cookie(response, access_token) 59 | return {"access_token": access_token} 60 | 61 | 62 | @app.get("/users/me") 63 | def read_current_user( 64 | credentials: JwtAuthorizationCredentials = Security(access_security), 65 | ): 66 | return {"username": credentials["username"], "role": credentials["role"]} 67 | ``` 68 | 69 | For more examples see usage docs 70 | 71 | 72 | ## Alternatives 73 | 74 | * FastAPI docs suggest [writing it manually](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/), but 75 | * code duplication 76 | * opportunity for bugs 77 | 78 | * There is nice [fastapi-jwt-auth](https://github.com/IndominusByte/fastapi-jwt-auth/), but 79 | * poorly supported 80 | * not "FastAPI-style" (not native functions parameters) 81 | 82 | ## FastAPI Integration 83 | 84 | There it is open and maintained [Pull Request #3305](https://github.com/tiangolo/fastapi/pull/3305) to the `fastapi` repo. Currently, not considered. 85 | 86 | ## Requirements 87 | 88 | * `fastapi` 89 | * `authlib` or `python-jose[cryptography]` (deprecated) 90 | 91 | ## License 92 | This project is licensed under the terms of the MIT license. 93 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.3.0 -------------------------------------------------------------------------------- /docs/docs/contributing.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../../CONTRIBUTING.md" 3 | heading-offset=0 4 | %} -------------------------------------------------------------------------------- /docs/docs/developing.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | Recommend to use venv for development. 4 | ```shell 5 | python3 -m venv .venv 6 | source .venv/bin/activate 7 | ``` 8 | 9 | Install dev dependencies 10 | ```shell 11 | python -m pip install .[docs,test] # \[docs,test\] in zsh 12 | ``` 13 | 14 | 15 | --- 16 | 17 | ## Python package 18 | 19 | ### Linting and Testing 20 | 21 | It is important NOT ONLY to get OK from all linters (or achieve score in the case of pylint), but also to write good code. 22 | P.S. It's hard to say what a Good Code is. Let's say that it should be simple, clear, commented, and so on. 23 | ```shell 24 | python -m flake8 . 25 | python -m mypy fastapi_jwt 26 | python -m isort . --check 27 | ``` 28 | 29 | Try NOT ONLY to achieve 100% coverage, but also to cover extreme cases, height load cases, multithreaded cases, incorrect input, and so on. 30 | ```shell 31 | python -m pytest 32 | ``` 33 | 34 | You can fix some issues in auto mode. 35 | 36 | * Sorting imports and make autopep. 37 | ```shell 38 | python -m isort . 39 | ``` 40 | 41 | 42 | ### Publishing 43 | 44 | Egg (deprecated) 45 | ```shell 46 | python3 setup.py build 47 | python3 setup.py sdist 48 | twine upload -r testpypi dist/* 49 | twine upload dist/* 50 | ``` 51 | 52 | Build Wheel and see what inside 53 | ```shell 54 | python3 -m pip wheel . --no-deps --wheel-dir dist 55 | tar --list -f dist/fastapi-jwt-0.0.1-py3-none-any.whl 56 | ``` 57 | 58 | Load dist to pypi 59 | ```shell 60 | twine upload -r testpypi dist/* 61 | twine upload dist/* 62 | ``` 63 | 64 | 65 | --- 66 | 67 | ## Docs 68 | 69 | ### Editing 70 | 71 | Edit it in `docs/` 72 | 73 | `mkdocs` can be run as dev server with auto-reload. 74 | ```shell 75 | mkdocs serve --config-file docs/mkdocs.yml 76 | ``` 77 | 78 | Note: Server will auto-restart for all changed `docs/*` files. 79 | If you want to edit `README.md` or `CONTRIBUTING.md` you should restart server on each change. 80 | 81 | 82 | ### Building pkg docs (`TODO`) 83 | 84 | Add python backend docs `TODO` 85 | ```shell 86 | lazydocs \ 87 | --output-path="./docs/references/backend" \ 88 | --overview-file="index.md" \ 89 | --src-base-url="https://github.com/k4black/flowingo/blob/master" \ 90 | flowingo 91 | ``` 92 | 93 | ### Deploy 94 | 95 | #### Without versioning (now) 96 | Build and deploy docs itself 97 | ```shell 98 | mkdocs build --config-file docs/mkdocs.yml 99 | mkdocs gh-deploy --config-file docs/mkdocs.yml 100 | ``` 101 | 102 | #### With `mike` as versioning tool (`TODO`) 103 | 104 | Deploy with `mike` to github-pages with versioning support 105 | ```shell 106 | mike deploy --config-file docs/mkdocs.yml 0.0.1 latest --push 107 | mike alias --config-file docs/mkdocs.yml 0.0.1 0.0.x --push 108 | mike set-default --config-file docs/mkdocs.yml latest --push 109 | ``` 110 | 111 | #### With `read-the-docs` as versioning tool (`TODO`) 112 | Deploy with `mkdocs` to read-the-docs for versioning support 113 | ```shell 114 | TODO 115 | ``` 116 | 117 | -------------------------------------------------------------------------------- /docs/docs/images: -------------------------------------------------------------------------------- 1 | ../images -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../../README.md" 3 | heading-offset=0 4 | %} -------------------------------------------------------------------------------- /docs/docs/overrides/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #b71ac7; 3 | --md-primary-fg-color--light: #9c0cab; 4 | --md-primary-fg-color--dark: #cf2de0; 5 | } -------------------------------------------------------------------------------- /docs/docs/release-notes.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../../CHANGELOG.md" 3 | heading-offset=0 4 | %} -------------------------------------------------------------------------------- /docs/docs/user_guide/classes.md: -------------------------------------------------------------------------------- 1 | # Classes 2 | 3 | This library made in fastapi style, so it can be used as standard security features 4 | 5 | 6 | ## Security classes 7 | 8 | #### Credentials 9 | 10 | * `JwtAuthorizationCredentials` - universal credentials for access and refresh tokens. 11 | Provide access to subject and unique token identifier (jti) 12 | ```python 13 | def foo(credentials: JwtAuthorizationCredentials = Security(access_security)): 14 | return credentials["username"], credentials.jti 15 | ``` 16 | 17 | #### Access tokens 18 | 19 | * `JwtAccessBearer` - read access token from bearer header only 20 | * `JwtAccessCookie` - read access token from cookies only 21 | * `JwtAccessBearerCookie` - read access token from both bearer and cookie 22 | 23 | #### Refresh tokens 24 | 25 | * `JwtRefreshBearer` - read access token from bearer header only 26 | * `JwtRefreshCookie` - read access token from cookies only 27 | * `JwtRefreshBearerCookie` - read access token from both bearer and cookie 28 | 29 | 30 | ### Create 31 | 32 | You can create `access_security` / `refresh_security` in multiple ways 33 | ```python 34 | # Manually 35 | access_security = JwtAccessBearerCookie( 36 | secret_key="other_secret_key", 37 | auto_error=True, 38 | access_expires_delta=timedelta(hours=1), # custom access token valid timedelta 39 | refresh_expires_delta=timedelta(days=1), # custom access token valid timedelta 40 | ) 41 | 42 | # Create from another object, copy all params 43 | refresh_security = JwtRefreshBearer.from_other(access_security) 44 | 45 | # Create from another object, rewrite some params 46 | other_access_security = JwtAccessCookie.from_other( 47 | access_security, 48 | secret_key='!key!', 49 | auto_error=False 50 | ) 51 | ``` 52 | 53 | -------------------------------------------------------------------------------- /docs/docs/user_guide/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This library made in fastapi style, so it can be used as standard security features 4 | 5 | 6 | ## Full Example 7 | 8 | 9 | ```python 10 | from datetime import timedelta 11 | 12 | from fastapi import FastAPI, Security, HTTPException 13 | from fastapi_jwt import ( 14 | JwtAccessBearerCookie, 15 | JwtAuthorizationCredentials, 16 | JwtRefreshBearer, 17 | ) 18 | 19 | 20 | app = FastAPI() 21 | 22 | 23 | # Read access token from bearer header and cookie (bearer priority) 24 | access_security = JwtAccessBearerCookie( 25 | secret_key="secret_key", 26 | auto_error=False, 27 | access_expires_delta=timedelta(hours=1) # change access token validation timedelta 28 | ) 29 | # Read refresh token from bearer header only 30 | refresh_security = JwtRefreshBearer( 31 | secret_key="secret_key", 32 | auto_error=True # automatically raise HTTPException: HTTP_401_UNAUTHORIZED 33 | ) 34 | 35 | 36 | @app.post("/auth") 37 | def auth(): 38 | # subject (actual payload) is any json-able python dict 39 | subject = {"username": "username", "role": "user"} 40 | 41 | # Create new access/refresh tokens pair 42 | access_token = access_security.create_access_token(subject=subject) 43 | refresh_token = refresh_security.create_refresh_token(subject=subject) 44 | 45 | return {"access_token": access_token, "refresh_token": refresh_token} 46 | 47 | 48 | @app.post("/refresh") 49 | def refresh( 50 | credentials: JwtAuthorizationCredentials = Security(refresh_security) 51 | ): 52 | # Update access/refresh tokens pair 53 | # We can customize expires_delta when creating 54 | access_token = access_security.create_access_token(subject=credentials.subject) 55 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject, expires_delta=timedelta(days=2)) 56 | 57 | return {"access_token": access_token, "refresh_token": refresh_token} 58 | 59 | 60 | @app.get("/users/me") 61 | def read_current_user( 62 | credentials: JwtAuthorizationCredentials = Security(access_security) 63 | ): 64 | # auto_error=False, so we should check manually 65 | if not credentials: 66 | raise HTTPException(status_code=401, detail='my-custom-details') 67 | 68 | # now we can access Credentials object 69 | return {"username": credentials["username"], "role": credentials["role"]} 70 | ``` 71 | -------------------------------------------------------------------------------- /docs/images/logo-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: fastapi-jwt 2 | site_description: FastAPI extention for JWT auth 3 | site_url: https://k4black.github.io/fastapi-jwt/ 4 | #docs_dir: ../docs 5 | docs_dir: ./docs 6 | #site_dir: ../site 7 | site_dir: ./site 8 | 9 | 10 | # Repository 11 | repo_name: k4black/fastapi-jwt 12 | repo_url: https://github.com/k4black/fastapi-jwt 13 | edit_uri: "" 14 | 15 | 16 | 17 | nav: 18 | - Home: index.md 19 | - User Guide: 20 | - Classes: user_guide/classes.md 21 | - Examples: user_guide/examples.md 22 | - Developing: developing.md 23 | - Release notes: release-notes.md 24 | 25 | 26 | theme: 27 | name: material 28 | custom_dir: docs/overrides 29 | palette: 30 | - media: "(prefers-color-scheme: light)" 31 | scheme: slate 32 | toggle: 33 | icon: material/weather-sunny 34 | name: Switch to light mode 35 | - media: "(prefers-color-scheme: dark)" 36 | scheme: default 37 | toggle: 38 | icon: material/weather-night 39 | name: Switch to dark mode 40 | font: 41 | text: Roboto 42 | code: Roboto Mono 43 | icon: 44 | repo: fontawesome/brands/github 45 | favicon: images/logo-circle.svg 46 | logo: images/logo-circle.svg 47 | 48 | extra_css: 49 | - overrides/main.css 50 | 51 | extra: 52 | version: 53 | provider: mike 54 | 55 | markdown_extensions: 56 | - pymdownx.highlight 57 | - pymdownx.inlinehilite 58 | - pymdownx.superfences 59 | - pymdownx.snippets 60 | 61 | plugins: 62 | - search 63 | - awesome-pages 64 | - include-markdown 65 | - mike 66 | -------------------------------------------------------------------------------- /docs/src/tmp.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k4black/fastapi-jwt/69c2251a71b7ba897371a5e7b976a170587f3421/docs/src/tmp.py -------------------------------------------------------------------------------- /fastapi_jwt/__init__.py: -------------------------------------------------------------------------------- 1 | from .jwt import * # noqa: F401, F403 2 | from .jwt_backends import * # noqa: F401, F403 3 | -------------------------------------------------------------------------------- /fastapi_jwt/jwt.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from datetime import datetime, timedelta 3 | from typing import Any, Dict, Optional, Set, Type 4 | from uuid import uuid4 5 | 6 | from fastapi.exceptions import HTTPException 7 | from fastapi.param_functions import Security 8 | from fastapi.responses import Response 9 | from fastapi.security import APIKeyCookie, HTTPBearer 10 | from starlette.status import HTTP_401_UNAUTHORIZED 11 | 12 | from .jwt_backends import AbstractJWTBackend, authlib_backend, python_jose_backend 13 | from .jwt_backends.abstract_backend import BackendException 14 | 15 | DEFAULT_JWT_BACKEND: Optional[Type[AbstractJWTBackend]] = None 16 | if authlib_backend.authlib_jose is not None: 17 | DEFAULT_JWT_BACKEND = authlib_backend.AuthlibJWTBackend 18 | elif python_jose_backend.jose is not None: 19 | DEFAULT_JWT_BACKEND = python_jose_backend.PythonJoseJWTBackend 20 | else: # pragma: nocover 21 | raise ImportError("No JWT backend found, please install 'python-jose' or 'authlib'") 22 | 23 | 24 | def force_jwt_backend(cls: Type[AbstractJWTBackend]) -> None: 25 | global DEFAULT_JWT_BACKEND 26 | DEFAULT_JWT_BACKEND = cls 27 | 28 | 29 | def utcnow() -> datetime: 30 | try: 31 | from datetime import UTC 32 | except ImportError: # pragma: nocover 33 | # UTC was added in python 3.12, as datetime.utcnow was 34 | # marked for deprecation. 35 | return datetime.utcnow() 36 | else: 37 | return datetime.now(UTC) 38 | 39 | 40 | __all__ = [ 41 | "force_jwt_backend", 42 | "JwtAuthorizationCredentials", 43 | "JwtAccessBearer", 44 | "JwtAccessCookie", 45 | "JwtAccessBearerCookie", 46 | "JwtRefreshBearer", 47 | "JwtRefreshCookie", 48 | "JwtRefreshBearerCookie", 49 | ] 50 | 51 | 52 | class JwtAuthorizationCredentials: 53 | def __init__(self, subject: Dict[str, Any], jti: Optional[str] = None): 54 | self.subject = subject 55 | self.jti = jti 56 | 57 | def __getitem__(self, item: str) -> Any: 58 | return self.subject[item] 59 | 60 | 61 | class JwtAuthBase(ABC): 62 | class JwtAccessCookie(APIKeyCookie): 63 | def __init__(self, *args: Any, **kwargs: Any): 64 | APIKeyCookie.__init__(self, *args, name="access_token_cookie", auto_error=False, **kwargs) 65 | 66 | class JwtRefreshCookie(APIKeyCookie): 67 | def __init__(self, *args: Any, **kwargs: Any): 68 | APIKeyCookie.__init__(self, *args, name="refresh_token_cookie", auto_error=False, **kwargs) 69 | 70 | class JwtAccessBearer(HTTPBearer): 71 | def __init__(self, *args: Any, **kwargs: Any): 72 | HTTPBearer.__init__(self, *args, auto_error=False, **kwargs) 73 | 74 | class JwtRefreshBearer(HTTPBearer): 75 | def __init__(self, *args: Any, **kwargs: Any): 76 | HTTPBearer.__init__(self, *args, auto_error=False, **kwargs) 77 | 78 | def __init__( 79 | self, 80 | secret_key: str, 81 | places: Optional[Set[str]] = None, 82 | auto_error: bool = True, 83 | algorithm: Optional[str] = None, 84 | access_expires_delta: Optional[timedelta] = None, 85 | refresh_expires_delta: Optional[timedelta] = None, 86 | ): 87 | assert DEFAULT_JWT_BACKEND is not None, "No JWT backend found, please install 'python-jose' or 'authlib'" 88 | 89 | self.jwt_backend = DEFAULT_JWT_BACKEND(algorithm) 90 | self.secret_key = secret_key 91 | 92 | self.places = places or {"header"} 93 | assert self.places.issubset({"header", "cookie"}), "only 'header' and/or 'cookie' places are supported" 94 | self.auto_error = auto_error 95 | self.access_expires_delta = access_expires_delta or timedelta(minutes=15) 96 | self.refresh_expires_delta = refresh_expires_delta or timedelta(days=31) 97 | 98 | @property 99 | def algorithm(self) -> str: 100 | return self.jwt_backend.algorithm 101 | 102 | @classmethod 103 | def from_other( 104 | cls, 105 | other: "JwtAuthBase", 106 | secret_key: Optional[str] = None, 107 | auto_error: Optional[bool] = None, 108 | algorithm: Optional[str] = None, 109 | access_expires_delta: Optional[timedelta] = None, 110 | refresh_expires_delta: Optional[timedelta] = None, 111 | ) -> "JwtAuthBase": 112 | return cls( 113 | secret_key=secret_key or other.secret_key, 114 | auto_error=auto_error or other.auto_error, 115 | algorithm=algorithm or other.algorithm, 116 | access_expires_delta=access_expires_delta or other.access_expires_delta, 117 | refresh_expires_delta=refresh_expires_delta or other.refresh_expires_delta, 118 | ) 119 | 120 | def _generate_payload( 121 | self, 122 | subject: Dict[str, Any], 123 | expires_delta: timedelta, 124 | unique_identifier: str, 125 | token_type: str, 126 | ) -> Dict[str, Any]: 127 | now = utcnow() 128 | return { 129 | "subject": subject.copy(), # main subject 130 | "type": token_type, # 'access' or 'refresh' token 131 | "exp": now + expires_delta, # expire time 132 | "iat": now, # creation time 133 | "jti": unique_identifier, # uuid 134 | } 135 | 136 | async def _get_payload( 137 | self, bearer: Optional[HTTPBearer], cookie: Optional[APIKeyCookie] 138 | ) -> Optional[Dict[str, Any]]: 139 | token: Optional[str] = None 140 | if bearer: 141 | token = str(bearer.credentials) # type: ignore 142 | elif cookie: 143 | token = str(cookie) 144 | 145 | # Check token exist 146 | if not token: 147 | if self.auto_error: 148 | raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Credentials are not provided") 149 | else: 150 | return None 151 | 152 | # Try to decode jwt token. auto_error on error 153 | try: 154 | return self.jwt_backend.decode(token, self.secret_key) 155 | except BackendException as e: 156 | if self.auto_error: 157 | raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=str(e)) 158 | else: 159 | return None 160 | 161 | def create_access_token( 162 | self, 163 | subject: Dict[str, Any], 164 | expires_delta: Optional[timedelta] = None, 165 | unique_identifier: Optional[str] = None, 166 | ) -> str: 167 | expires_delta = expires_delta or self.access_expires_delta 168 | unique_identifier = unique_identifier or str(uuid4()) 169 | to_encode = self._generate_payload(subject, expires_delta, unique_identifier, "access") 170 | return self.jwt_backend.encode(to_encode, self.secret_key) 171 | 172 | def create_refresh_token( 173 | self, 174 | subject: Dict[str, Any], 175 | expires_delta: Optional[timedelta] = None, 176 | unique_identifier: Optional[str] = None, 177 | ) -> str: 178 | expires_delta = expires_delta or self.refresh_expires_delta 179 | unique_identifier = unique_identifier or str(uuid4()) 180 | to_encode = self._generate_payload(subject, expires_delta, unique_identifier, "refresh") 181 | return self.jwt_backend.encode(to_encode, self.secret_key) 182 | 183 | @staticmethod 184 | def set_access_cookie(response: Response, access_token: str, expires_delta: Optional[timedelta] = None) -> None: 185 | seconds_expires: Optional[int] = int(expires_delta.total_seconds()) if expires_delta else None 186 | response.set_cookie( 187 | key="access_token_cookie", 188 | value=access_token, 189 | httponly=False, 190 | max_age=seconds_expires, 191 | ) 192 | 193 | @staticmethod 194 | def set_refresh_cookie( 195 | response: Response, 196 | refresh_token: str, 197 | expires_delta: Optional[timedelta] = None, 198 | ) -> None: 199 | seconds_expires: Optional[int] = int(expires_delta.total_seconds()) if expires_delta else None 200 | response.set_cookie( 201 | key="refresh_token_cookie", 202 | value=refresh_token, 203 | httponly=True, 204 | max_age=seconds_expires, 205 | ) 206 | 207 | @staticmethod 208 | def unset_access_cookie(response: Response) -> None: 209 | response.set_cookie(key="access_token_cookie", value="", httponly=False, max_age=-1) 210 | 211 | @staticmethod 212 | def unset_refresh_cookie(response: Response) -> None: 213 | response.set_cookie(key="refresh_token_cookie", value="", httponly=True, max_age=-1) 214 | 215 | 216 | class JwtAccess(JwtAuthBase): 217 | _bearer = JwtAuthBase.JwtAccessBearer() 218 | _cookie = JwtAuthBase.JwtAccessCookie() 219 | 220 | def __init__( 221 | self, 222 | secret_key: str, 223 | places: Optional[Set[str]] = None, 224 | auto_error: bool = True, 225 | algorithm: Optional[str] = None, 226 | access_expires_delta: Optional[timedelta] = None, 227 | refresh_expires_delta: Optional[timedelta] = None, 228 | ): 229 | super().__init__( 230 | secret_key, 231 | places=places, 232 | auto_error=auto_error, 233 | algorithm=algorithm, 234 | access_expires_delta=access_expires_delta, 235 | refresh_expires_delta=refresh_expires_delta, 236 | ) 237 | 238 | async def _get_credentials( 239 | self, 240 | bearer: Optional[JwtAuthBase.JwtAccessBearer], 241 | cookie: Optional[JwtAuthBase.JwtAccessCookie], 242 | ) -> Optional[JwtAuthorizationCredentials]: 243 | payload = await self._get_payload(bearer, cookie) 244 | 245 | if payload: 246 | return JwtAuthorizationCredentials(payload["subject"], payload.get("jti", None)) 247 | return None 248 | 249 | 250 | class JwtAccessBearer(JwtAccess): 251 | def __init__( 252 | self, 253 | secret_key: str, 254 | auto_error: bool = True, 255 | algorithm: Optional[str] = None, 256 | access_expires_delta: Optional[timedelta] = None, 257 | refresh_expires_delta: Optional[timedelta] = None, 258 | ): 259 | super().__init__( 260 | secret_key=secret_key, 261 | places={"header"}, 262 | auto_error=auto_error, 263 | algorithm=algorithm, 264 | access_expires_delta=access_expires_delta, 265 | refresh_expires_delta=refresh_expires_delta, 266 | ) 267 | 268 | async def __call__( 269 | self, bearer: JwtAuthBase.JwtAccessBearer = Security(JwtAccess._bearer) 270 | ) -> Optional[JwtAuthorizationCredentials]: 271 | return await self._get_credentials(bearer=bearer, cookie=None) 272 | 273 | 274 | class JwtAccessCookie(JwtAccess): 275 | def __init__( 276 | self, 277 | secret_key: str, 278 | auto_error: bool = True, 279 | algorithm: Optional[str] = None, 280 | access_expires_delta: Optional[timedelta] = None, 281 | refresh_expires_delta: Optional[timedelta] = None, 282 | ): 283 | super().__init__( 284 | secret_key=secret_key, 285 | places={"cookie"}, 286 | auto_error=auto_error, 287 | algorithm=algorithm, 288 | access_expires_delta=access_expires_delta, 289 | refresh_expires_delta=refresh_expires_delta, 290 | ) 291 | 292 | async def __call__( 293 | self, 294 | cookie: JwtAuthBase.JwtAccessCookie = Security(JwtAccess._cookie), 295 | ) -> Optional[JwtAuthorizationCredentials]: 296 | return await self._get_credentials(bearer=None, cookie=cookie) 297 | 298 | 299 | class JwtAccessBearerCookie(JwtAccess): 300 | def __init__( 301 | self, 302 | secret_key: str, 303 | auto_error: bool = True, 304 | algorithm: Optional[str] = None, 305 | access_expires_delta: Optional[timedelta] = None, 306 | refresh_expires_delta: Optional[timedelta] = None, 307 | ): 308 | super().__init__( 309 | secret_key=secret_key, 310 | places={"header", "cookie"}, 311 | auto_error=auto_error, 312 | algorithm=algorithm, 313 | access_expires_delta=access_expires_delta, 314 | refresh_expires_delta=refresh_expires_delta, 315 | ) 316 | 317 | async def __call__( 318 | self, 319 | bearer: JwtAuthBase.JwtAccessBearer = Security(JwtAccess._bearer), 320 | cookie: JwtAuthBase.JwtAccessCookie = Security(JwtAccess._cookie), 321 | ) -> Optional[JwtAuthorizationCredentials]: 322 | return await self._get_credentials(bearer=bearer, cookie=cookie) 323 | 324 | 325 | class JwtRefresh(JwtAuthBase): 326 | _bearer = JwtAuthBase.JwtRefreshBearer() 327 | _cookie = JwtAuthBase.JwtRefreshCookie() 328 | 329 | def __init__( 330 | self, 331 | secret_key: str, 332 | places: Optional[Set[str]] = None, 333 | auto_error: bool = True, 334 | algorithm: Optional[str] = None, 335 | access_expires_delta: Optional[timedelta] = None, 336 | refresh_expires_delta: Optional[timedelta] = None, 337 | ): 338 | super().__init__( 339 | secret_key, 340 | places=places, 341 | auto_error=auto_error, 342 | algorithm=algorithm, 343 | access_expires_delta=access_expires_delta, 344 | refresh_expires_delta=refresh_expires_delta, 345 | ) 346 | 347 | async def _get_credentials( 348 | self, 349 | bearer: Optional[JwtAuthBase.JwtRefreshBearer], 350 | cookie: Optional[JwtAuthBase.JwtRefreshCookie], 351 | ) -> Optional[JwtAuthorizationCredentials]: 352 | payload = await self._get_payload(bearer, cookie) 353 | 354 | if payload is None: 355 | return None 356 | 357 | if "type" not in payload or payload["type"] != "refresh": 358 | if self.auto_error: 359 | raise HTTPException( 360 | status_code=HTTP_401_UNAUTHORIZED, 361 | detail="Invalid token: 'type' is not 'refresh'", 362 | ) 363 | else: 364 | return None 365 | 366 | return JwtAuthorizationCredentials(payload["subject"], payload.get("jti", None)) 367 | 368 | 369 | class JwtRefreshBearer(JwtRefresh): 370 | def __init__( 371 | self, 372 | secret_key: str, 373 | auto_error: bool = True, 374 | algorithm: Optional[str] = None, 375 | access_expires_delta: Optional[timedelta] = None, 376 | refresh_expires_delta: Optional[timedelta] = None, 377 | ): 378 | super().__init__( 379 | secret_key=secret_key, 380 | places={"header"}, 381 | auto_error=auto_error, 382 | algorithm=algorithm, 383 | access_expires_delta=access_expires_delta, 384 | refresh_expires_delta=refresh_expires_delta, 385 | ) 386 | 387 | async def __call__( 388 | self, bearer: JwtAuthBase.JwtRefreshBearer = Security(JwtRefresh._bearer) 389 | ) -> Optional[JwtAuthorizationCredentials]: 390 | return await self._get_credentials(bearer=bearer, cookie=None) 391 | 392 | 393 | class JwtRefreshCookie(JwtRefresh): 394 | def __init__( 395 | self, 396 | secret_key: str, 397 | auto_error: bool = True, 398 | algorithm: Optional[str] = None, 399 | access_expires_delta: Optional[timedelta] = None, 400 | refresh_expires_delta: Optional[timedelta] = None, 401 | ): 402 | super().__init__( 403 | secret_key=secret_key, 404 | places={"cookie"}, 405 | auto_error=auto_error, 406 | algorithm=algorithm, 407 | access_expires_delta=access_expires_delta, 408 | refresh_expires_delta=refresh_expires_delta, 409 | ) 410 | 411 | async def __call__( 412 | self, 413 | cookie: JwtAuthBase.JwtRefreshCookie = Security(JwtRefresh._cookie), 414 | ) -> Optional[JwtAuthorizationCredentials]: 415 | return await self._get_credentials(bearer=None, cookie=cookie) 416 | 417 | 418 | class JwtRefreshBearerCookie(JwtRefresh): 419 | def __init__( 420 | self, 421 | secret_key: str, 422 | auto_error: bool = True, 423 | algorithm: Optional[str] = None, 424 | access_expires_delta: Optional[timedelta] = None, 425 | refresh_expires_delta: Optional[timedelta] = None, 426 | ): 427 | super().__init__( 428 | secret_key=secret_key, 429 | places={"header", "cookie"}, 430 | auto_error=auto_error, 431 | algorithm=algorithm, 432 | access_expires_delta=access_expires_delta, 433 | refresh_expires_delta=refresh_expires_delta, 434 | ) 435 | 436 | async def __call__( 437 | self, 438 | bearer: JwtAuthBase.JwtRefreshBearer = Security(JwtRefresh._bearer), 439 | cookie: JwtAuthBase.JwtRefreshCookie = Security(JwtRefresh._cookie), 440 | ) -> Optional[JwtAuthorizationCredentials]: 441 | return await self._get_credentials(bearer=bearer, cookie=cookie) 442 | -------------------------------------------------------------------------------- /fastapi_jwt/jwt_backends/__init__.py: -------------------------------------------------------------------------------- 1 | from . import abstract_backend, authlib_backend, python_jose_backend # noqa: F401 2 | from .abstract_backend import AbstractJWTBackend # noqa: F401 3 | from .authlib_backend import AuthlibJWTBackend # noqa: F401 4 | from .python_jose_backend import PythonJoseJWTBackend # noqa: F401 5 | -------------------------------------------------------------------------------- /fastapi_jwt/jwt_backends/abstract_backend.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Dict, Optional 3 | 4 | 5 | class BackendException(Exception): # pragma: no cover 6 | pass 7 | 8 | 9 | class AbstractJWTBackend(ABC): # pragma: no cover 10 | @abstractmethod 11 | def __init__(self, algorithm: Optional[str] = None) -> None: 12 | raise NotImplementedError 13 | 14 | @property 15 | @abstractmethod 16 | def algorithm(self) -> str: 17 | raise NotImplementedError 18 | 19 | @abstractmethod 20 | def encode(self, to_encode: Dict[str, Any], secret_key: str) -> str: 21 | raise NotImplementedError 22 | 23 | @abstractmethod 24 | def decode(self, token: str, secret_key: str) -> Optional[Dict[str, Any]]: 25 | raise NotImplementedError 26 | -------------------------------------------------------------------------------- /fastapi_jwt/jwt_backends/authlib_backend.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | try: 4 | import authlib.jose as authlib_jose 5 | import authlib.jose.errors as authlib_jose_errors 6 | except ImportError: # pragma: no cover 7 | authlib_jose = None 8 | 9 | from .abstract_backend import AbstractJWTBackend, BackendException 10 | 11 | 12 | class AuthlibJWTBackend(AbstractJWTBackend): 13 | def __init__(self, algorithm: Optional[str] = None) -> None: 14 | assert authlib_jose is not None, "To use AuthlibJWTBackend, you need to install authlib" 15 | 16 | self._algorithm = algorithm or self.default_algorithm 17 | # from https://github.com/lepture/authlib/blob/85f9ff/authlib/jose/__init__.py#L45 18 | assert ( 19 | self._algorithm in authlib_jose.JsonWebSignature.ALGORITHMS_REGISTRY.keys() 20 | ), f"{self._algorithm} algorithm is not supported by authlib" 21 | self.jwt = authlib_jose.JsonWebToken(algorithms=[self._algorithm]) 22 | 23 | @property 24 | def default_algorithm(self) -> str: 25 | return "HS256" 26 | 27 | @property 28 | def algorithm(self) -> str: 29 | return self._algorithm 30 | 31 | def encode(self, to_encode: Dict[str, Any], secret_key: str) -> str: 32 | token = self.jwt.encode(header={"alg": self.algorithm}, payload=to_encode, key=secret_key) 33 | return token.decode() # convert to string 34 | 35 | def decode(self, token: str, secret_key: str) -> Optional[Dict[str, Any]]: 36 | try: 37 | payload = self.jwt.decode(token, secret_key) 38 | payload.validate(leeway=10) 39 | return dict(payload) 40 | except authlib_jose_errors.ExpiredTokenError as e: 41 | raise BackendException(f"Token time expired: {e}") 42 | except ( 43 | authlib_jose_errors.InvalidClaimError, 44 | authlib_jose_errors.InvalidTokenError, 45 | authlib_jose_errors.DecodeError, 46 | ) as e: 47 | raise BackendException(f"Invalid token: {e}") 48 | -------------------------------------------------------------------------------- /fastapi_jwt/jwt_backends/python_jose_backend.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Any, Dict, Optional 3 | 4 | try: 5 | import jose 6 | import jose.jwt 7 | except ImportError: # pragma: no cover 8 | jose = None # type: ignore 9 | 10 | from .abstract_backend import AbstractJWTBackend, BackendException 11 | 12 | 13 | class PythonJoseJWTBackend(AbstractJWTBackend): 14 | def __init__(self, algorithm: Optional[str] = None) -> None: 15 | assert jose is not None, "To use PythonJoseJWTBackend, you need to install python-jose" 16 | warnings.warn("PythonJoseJWTBackend is deprecated as python-jose library is not maintained anymore.") 17 | 18 | self._algorithm = algorithm or self.default_algorithm 19 | assert ( 20 | hasattr(jose.jwt.ALGORITHMS, self._algorithm) is True # type: ignore[attr-defined] 21 | ), f"{algorithm} algorithm is not supported by python-jose library" 22 | 23 | @property 24 | def default_algorithm(self) -> str: 25 | return jose.jwt.ALGORITHMS.HS256 # type: ignore[attr-defined] 26 | 27 | @property 28 | def algorithm(self) -> str: 29 | return self._algorithm 30 | 31 | def encode(self, to_encode: Dict[str, Any], secret_key: str) -> str: 32 | return jose.jwt.encode(to_encode, secret_key, algorithm=self._algorithm) 33 | 34 | def decode(self, token: str, secret_key: str) -> Optional[Dict[str, Any]]: 35 | try: 36 | payload: Dict[str, Any] = jose.jwt.decode( 37 | token, 38 | secret_key, 39 | algorithms=[self._algorithm], 40 | options={"leeway": 10}, 41 | ) 42 | return payload 43 | except jose.jwt.ExpiredSignatureError as e: # type: ignore[attr-defined] 44 | raise BackendException(f"Token time expired: {e}") 45 | except jose.jwt.JWTError as e: # type: ignore[attr-defined] 46 | raise BackendException(f"Invalid token: {e}") 47 | -------------------------------------------------------------------------------- /fastapi_jwt/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k4black/fastapi-jwt/69c2251a71b7ba897371a5e7b976a170587f3421/fastapi_jwt/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=60.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | 6 | [project] 7 | name = "fastapi-jwt" 8 | description = "`FastAPI` extension for JTW Auth" 9 | readme = "README.md" 10 | license = {text = "MIT License"} 11 | authors = [ 12 | {name = "Konstantin Chernyshev", email = "kdchernyshev@gmail.com"}, 13 | ] 14 | dynamic = ["version"] 15 | 16 | requires-python = ">=3.8" 17 | classifiers = [ 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: Information Technology", 20 | "Operating System :: OS Independent", 21 | "Topic :: Software Development :: Libraries :: Application Frameworks", 22 | "Development Status :: 4 - Beta", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Typing :: Typed" 26 | ] 27 | 28 | dependencies = [ 29 | "fastapi >=0.50.0", 30 | ] 31 | 32 | 33 | [project.urls] 34 | homepage = "https://github.com/k4black/fastapi-jwt" 35 | documentation = "https://k4black.github.io/fastapi-jwt/" 36 | 37 | 38 | [project.optional-dependencies] 39 | authlib = [ 40 | "Authlib >=1.3.0" 41 | ] 42 | python_jose = [ 43 | "python-jose[cryptography] >=3.3.0" 44 | ] 45 | test = [ 46 | "Authlib >=1.3.0", 47 | "python-jose[cryptography] >=3.3.0", 48 | "httpx >=0.23.0,<1.0.0", 49 | "pytest >=7.0.0,<9.0.0", 50 | "pytest-cov >=4.0.0,<7.0.0", 51 | "pytest-mock >=3.0.0,<4.0.0", 52 | "requests >=2.28.0,<3.0.0", 53 | "black ==24.10.0", 54 | "mypy >=1.0.0,<2.0.0", 55 | "flake8 >=6.0.0,<8.0.0", 56 | "ruff >=0.1.0,<1.0.0", 57 | "isort >=5.11.0,<6.0.0", 58 | "types-python-jose ==3.3.4.8" 59 | ] 60 | docs = [ 61 | "mkdocs >=1.4.0,<2.0.0", 62 | "mkdocs-material >=9.0.0,<10.0.0", 63 | "MkAutoDoc >=0.2.0,<1.0.0", 64 | "lazydocs >=0.4.5,<1.0.0", 65 | "mkdocs-include-markdown-plugin >=4.0.0,<7.0.0", 66 | "mkdocs-awesome-pages-plugin >=2.8.0,<3.0.0", 67 | "mike >=1.1.0,<3.0.0" 68 | ] 69 | 70 | 71 | [tool.setuptools.dynamic] 72 | version = {file = "VERSION"} 73 | 74 | [tool.mypy] 75 | ignore_missing_imports = true 76 | no_incremental = true 77 | disallow_untyped_defs = true 78 | disallow_incomplete_defs = true 79 | disallow_subclassing_any = false 80 | disallow_any_generics = true 81 | no_implicit_optional = true 82 | warn_redundant_casts = true 83 | warn_unused_ignores = true 84 | warn_unreachable = true 85 | allow_untyped_decorators = true 86 | 87 | 88 | [tool.pytest.ini_options] 89 | minversion = "6.0" 90 | testpaths = ["tests"] 91 | python_files = "test_*.py" 92 | addopts = "--cov=fastapi_jwt/ --cov-report term-missing" 93 | 94 | 95 | # TODO: remove in favor of ruff "I" ruleset 96 | [tool.isort] 97 | profile = "black" 98 | src_paths = ["fastapi-jwt", "tests"] 99 | known_first_party = ["fastapi_jwt", "tests"] 100 | line_length = 120 101 | combine_as_imports = true 102 | 103 | 104 | [tool.ruff] 105 | line-length = 120 106 | target-version = "py312" 107 | 108 | [tool.ruff.lint] 109 | select = ["E", "F", "W", "I"] 110 | ignore = [] 111 | 112 | fixable = ["ALL"] # Allow fix for all enabled rules (when `--fix`) is provided. 113 | unfixable = [] 114 | 115 | [tool.ruff.format] 116 | quote-style = "double" 117 | indent-style = "space" 118 | skip-magic-trailing-comma = false 119 | line-ending = "auto" 120 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # unable to move to pyproject.toml, issue: https://github.com/PyCQA/flake8/issues/234 2 | [flake8] 3 | # classes can be lowercase, arguments and variables can be uppercase 4 | # whenever it makes the code more readable. 5 | exclude = 6 | .git, 7 | __pycache__, 8 | .venv, 9 | test_*.py 10 | max-line-length = 120 11 | doctests = True 12 | max-doc-length = 120 13 | exclude-from-doctest = test_*.py 14 | max-complexity = 10 15 | enable-extensions = pep8-naming,flake8-debugger,flake8-docstrings 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k4black/fastapi-jwt/69c2251a71b7ba897371a5e7b976a170587f3421/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | import pytest 4 | 5 | from fastapi_jwt.jwt_backends import AbstractJWTBackend, AuthlibJWTBackend, PythonJoseJWTBackend 6 | 7 | 8 | @pytest.fixture(params=[PythonJoseJWTBackend, AuthlibJWTBackend]) 9 | def jwt_backend(request: pytest.FixtureRequest) -> Type[AbstractJWTBackend]: 10 | return request.param 11 | -------------------------------------------------------------------------------- /tests/mock_datetime_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | 4 | from fastapi_jwt import AuthlibJWTBackend, PythonJoseJWTBackend 5 | 6 | _time = time.time 7 | _now = datetime.datetime.now 8 | _utcnow = datetime.datetime.utcnow 9 | 10 | 11 | def create_datetime_mock(**timedelta_kwargs): 12 | class _FakeDateTime(datetime.datetime): # pragma: no cover 13 | @staticmethod 14 | def now(**kwargs): 15 | return _now() + datetime.timedelta(**timedelta_kwargs) 16 | 17 | @staticmethod 18 | def utcnow(**kwargs): 19 | return _utcnow() + datetime.timedelta(**timedelta_kwargs) 20 | 21 | return _FakeDateTime 22 | 23 | 24 | def create_time_time_mock(**kwargs): 25 | def _fake_time_time(): 26 | return _time() + datetime.timedelta(**kwargs).total_seconds() 27 | 28 | return _fake_time_time 29 | 30 | 31 | def mock_now_for_backend(mocker, jwt_backend, **kwargs): 32 | if jwt_backend is AuthlibJWTBackend: 33 | mocker.patch("authlib.jose.rfc7519.claims.time.time", create_time_time_mock(**kwargs)) 34 | elif jwt_backend is PythonJoseJWTBackend: 35 | mocker.patch("jose.jwt.datetime", create_datetime_mock(**kwargs)) 36 | else: 37 | raise Exception("Invalid Backend") 38 | -------------------------------------------------------------------------------- /tests/test_security_jwt_bearer.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from fastapi import FastAPI, Security 4 | from fastapi.testclient import TestClient 5 | 6 | from fastapi_jwt import JwtAccessBearer, JwtAuthorizationCredentials, JwtRefreshBearer, force_jwt_backend 7 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 8 | 9 | 10 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 11 | force_jwt_backend(jwt_backend) 12 | app = FastAPI() 13 | 14 | access_security = JwtAccessBearer(secret_key="secret_key") 15 | refresh_security = JwtRefreshBearer(secret_key="secret_key") 16 | 17 | @app.post("/auth") 18 | def auth(): 19 | subject = {"username": "username", "role": "user"} 20 | 21 | access_token = access_security.create_access_token(subject=subject) 22 | refresh_token = access_security.create_refresh_token(subject=subject) 23 | 24 | return {"access_token": access_token, "refresh_token": refresh_token} 25 | 26 | @app.post("/refresh") 27 | def refresh(credentials: JwtAuthorizationCredentials = Security(refresh_security)): 28 | access_token = refresh_security.create_access_token(subject=credentials.subject) 29 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject) 30 | 31 | return {"access_token": access_token, "refresh_token": refresh_token} 32 | 33 | @app.get("/users/me") 34 | def read_current_user( 35 | credentials: JwtAuthorizationCredentials = Security(access_security), 36 | ): 37 | return {"username": credentials["username"], "role": credentials["role"]} 38 | 39 | return TestClient(app) 40 | 41 | 42 | openapi_schema = { 43 | "openapi": "3.1.0", 44 | "info": {"title": "FastAPI", "version": "0.1.0"}, 45 | "paths": { 46 | "/auth": { 47 | "post": { 48 | "responses": { 49 | "200": { 50 | "description": "Successful Response", 51 | "content": {"application/json": {"schema": {}}}, 52 | } 53 | }, 54 | "summary": "Auth", 55 | "operationId": "auth_auth_post", 56 | } 57 | }, 58 | "/refresh": { 59 | "post": { 60 | "responses": { 61 | "200": { 62 | "description": "Successful Response", 63 | "content": {"application/json": {"schema": {}}}, 64 | } 65 | }, 66 | "summary": "Refresh", 67 | "operationId": "refresh_refresh_post", 68 | "security": [{"JwtRefreshBearer": []}], 69 | } 70 | }, 71 | "/users/me": { 72 | "get": { 73 | "responses": { 74 | "200": { 75 | "description": "Successful Response", 76 | "content": {"application/json": {"schema": {}}}, 77 | } 78 | }, 79 | "summary": "Read Current User", 80 | "operationId": "read_current_user_users_me_get", 81 | "security": [{"JwtAccessBearer": []}], 82 | } 83 | }, 84 | }, 85 | "components": { 86 | "securitySchemes": { 87 | "JwtAccessBearer": {"type": "http", "scheme": "bearer"}, 88 | "JwtRefreshBearer": {"type": "http", "scheme": "bearer"}, 89 | } 90 | }, 91 | } 92 | 93 | 94 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 95 | client = create_example_client(jwt_backend) 96 | response = client.get("/openapi.json") 97 | assert response.status_code == 200, response.text 98 | assert response.json() == openapi_schema 99 | 100 | 101 | def test_security_jwt_auth(jwt_backend: Type[AbstractJWTBackend]): 102 | client = create_example_client(jwt_backend) 103 | response = client.post("/auth") 104 | assert response.status_code == 200, response.text 105 | 106 | 107 | def test_security_jwt_access_bearer(jwt_backend: Type[AbstractJWTBackend]): 108 | client = create_example_client(jwt_backend) 109 | access_token = client.post("/auth").json()["access_token"] 110 | 111 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 112 | assert response.status_code == 200, response.text 113 | assert response.json() == {"username": "username", "role": "user"} 114 | 115 | 116 | def test_security_jwt_access_bearer_wrong(jwt_backend: Type[AbstractJWTBackend]): 117 | client = create_example_client(jwt_backend) 118 | response = client.get("/users/me", headers={"Authorization": "Bearer wrong_access_token"}) 119 | assert response.status_code == 401, response.text 120 | 121 | 122 | def test_security_jwt_access_bearer_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 123 | client = create_example_client(jwt_backend) 124 | response = client.get("/users/me") 125 | assert response.status_code == 401, response.text 126 | assert response.json() == {"detail": "Credentials are not provided"} 127 | 128 | 129 | def test_security_jwt_access_bearer_incorrect_scheme_credentials(jwt_backend: Type[AbstractJWTBackend]): 130 | client = create_example_client(jwt_backend) 131 | response = client.get("/users/me", headers={"Authorization": "Basic notreally"}) 132 | assert response.status_code == 401, response.text 133 | assert response.json() == {"detail": "Credentials are not provided"} 134 | # assert response.json() == {"detail": "Invalid authentication credentials"} 135 | 136 | 137 | def test_security_jwt_refresh_bearer(jwt_backend: Type[AbstractJWTBackend]): 138 | client = create_example_client(jwt_backend) 139 | refresh_token = client.post("/auth").json()["refresh_token"] 140 | 141 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 142 | assert response.status_code == 200, response.text 143 | 144 | 145 | def test_security_jwt_refresh_bearer_wrong(jwt_backend: Type[AbstractJWTBackend]): 146 | client = create_example_client(jwt_backend) 147 | response = client.post("/refresh", headers={"Authorization": "Bearer wrong_refresh_token"}) 148 | assert response.status_code == 401, response.text 149 | 150 | 151 | def test_security_jwt_refresh_bearer_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 152 | client = create_example_client(jwt_backend) 153 | response = client.post("/refresh") 154 | assert response.status_code == 401, response.text 155 | assert response.json() == {"detail": "Credentials are not provided"} 156 | 157 | 158 | def test_security_jwt_refresh_bearer_incorrect_scheme_credentials(jwt_backend: Type[AbstractJWTBackend]): 159 | client = create_example_client(jwt_backend) 160 | response = client.post("/refresh", headers={"Authorization": "Basic notreally"}) 161 | assert response.status_code == 401, response.text 162 | assert response.json() == {"detail": "Credentials are not provided"} 163 | # assert response.json() == {"detail": "Invalid authentication credentials"} 164 | -------------------------------------------------------------------------------- /tests/test_security_jwt_bearer_optional.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from fastapi import FastAPI, Security 4 | from fastapi.testclient import TestClient 5 | 6 | from fastapi_jwt import JwtAccessBearer, JwtAuthorizationCredentials, JwtRefreshBearer, force_jwt_backend 7 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 8 | 9 | 10 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 11 | force_jwt_backend(jwt_backend) 12 | app = FastAPI() 13 | 14 | access_security = JwtAccessBearer(secret_key="secret_key", auto_error=False) 15 | refresh_security = JwtRefreshBearer(secret_key="secret_key", auto_error=False) 16 | 17 | @app.post("/auth") 18 | def auth(): 19 | subject = {"username": "username", "role": "user"} 20 | 21 | access_token = access_security.create_access_token(subject=subject) 22 | refresh_token = access_security.create_refresh_token(subject=subject) 23 | 24 | return {"access_token": access_token, "refresh_token": refresh_token} 25 | 26 | @app.post("/refresh") 27 | def refresh( 28 | credentials: Optional[JwtAuthorizationCredentials] = Security(refresh_security), 29 | ): 30 | if credentials is None: 31 | return {"msg": "Create an account first"} 32 | 33 | access_token = refresh_security.create_access_token(subject=credentials.subject) 34 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject) 35 | 36 | return {"access_token": access_token, "refresh_token": refresh_token} 37 | 38 | @app.get("/users/me") 39 | def read_current_user( 40 | credentials: Optional[JwtAuthorizationCredentials] = Security(access_security), 41 | ): 42 | if credentials is None: 43 | return {"msg": "Create an account first"} 44 | return {"username": credentials["username"], "role": credentials["role"]} 45 | 46 | return TestClient(app) 47 | 48 | 49 | openapi_schema = { 50 | "openapi": "3.1.0", 51 | "info": {"title": "FastAPI", "version": "0.1.0"}, 52 | "paths": { 53 | "/auth": { 54 | "post": { 55 | "responses": { 56 | "200": { 57 | "description": "Successful Response", 58 | "content": {"application/json": {"schema": {}}}, 59 | } 60 | }, 61 | "summary": "Auth", 62 | "operationId": "auth_auth_post", 63 | } 64 | }, 65 | "/refresh": { 66 | "post": { 67 | "responses": { 68 | "200": { 69 | "description": "Successful Response", 70 | "content": {"application/json": {"schema": {}}}, 71 | } 72 | }, 73 | "summary": "Refresh", 74 | "operationId": "refresh_refresh_post", 75 | "security": [{"JwtRefreshBearer": []}], 76 | } 77 | }, 78 | "/users/me": { 79 | "get": { 80 | "responses": { 81 | "200": { 82 | "description": "Successful Response", 83 | "content": {"application/json": {"schema": {}}}, 84 | } 85 | }, 86 | "summary": "Read Current User", 87 | "operationId": "read_current_user_users_me_get", 88 | "security": [{"JwtAccessBearer": []}], 89 | } 90 | }, 91 | }, 92 | "components": { 93 | "securitySchemes": { 94 | "JwtAccessBearer": {"type": "http", "scheme": "bearer"}, 95 | "JwtRefreshBearer": {"type": "http", "scheme": "bearer"}, 96 | } 97 | }, 98 | } 99 | 100 | 101 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 102 | client = create_example_client(jwt_backend) 103 | response = client.get("/openapi.json") 104 | assert response.status_code == 200, response.text 105 | assert response.json() == openapi_schema 106 | 107 | 108 | def test_security_jwt_auth(jwt_backend: Type[AbstractJWTBackend]): 109 | client = create_example_client(jwt_backend) 110 | response = client.post("/auth") 111 | assert response.status_code == 200, response.text 112 | 113 | 114 | def test_security_jwt_access_bearer(jwt_backend: Type[AbstractJWTBackend]): 115 | client = create_example_client(jwt_backend) 116 | access_token = client.post("/auth").json()["access_token"] 117 | 118 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 119 | assert response.status_code == 200, response.text 120 | assert response.json() == {"username": "username", "role": "user"} 121 | 122 | 123 | def test_security_jwt_access_bearer_wrong(jwt_backend: Type[AbstractJWTBackend]): 124 | client = create_example_client(jwt_backend) 125 | response = client.get("/users/me", headers={"Authorization": "Bearer wrong_access_token"}) 126 | assert response.status_code == 200, response.text 127 | assert response.json() == {"msg": "Create an account first"} 128 | 129 | 130 | def test_security_jwt_access_bearer_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 131 | client = create_example_client(jwt_backend) 132 | response = client.get("/users/me") 133 | assert response.status_code == 200, response.text 134 | assert response.json() == {"msg": "Create an account first"} 135 | 136 | 137 | def test_security_jwt_access_bearer_incorrect_scheme_credentials(jwt_backend: Type[AbstractJWTBackend]): 138 | client = create_example_client(jwt_backend) 139 | response = client.get("/users/me", headers={"Authorization": "Basic notreally"}) 140 | assert response.status_code == 200, response.text 141 | assert response.json() == {"msg": "Create an account first"} 142 | 143 | 144 | def test_security_jwt_refresh_bearer(jwt_backend: Type[AbstractJWTBackend]): 145 | client = create_example_client(jwt_backend) 146 | refresh_token = client.post("/auth").json()["refresh_token"] 147 | 148 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 149 | assert response.status_code == 200, response.text 150 | 151 | 152 | def test_security_jwt_refresh_bearer_wrong(jwt_backend: Type[AbstractJWTBackend]): 153 | client = create_example_client(jwt_backend) 154 | response = client.post("/refresh", headers={"Authorization": "Bearer wrong_refresh_token"}) 155 | assert response.status_code == 200, response.text 156 | assert response.json() == {"msg": "Create an account first"} 157 | 158 | 159 | def test_security_jwt_refresh_bearer_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 160 | client = create_example_client(jwt_backend) 161 | response = client.post("/refresh") 162 | assert response.status_code == 200, response.text 163 | assert response.json() == {"msg": "Create an account first"} 164 | 165 | 166 | def test_security_jwt_refresh_bearer_incorrect_scheme_credentials(jwt_backend: Type[AbstractJWTBackend]): 167 | client = create_example_client(jwt_backend) 168 | response = client.post("/refresh", headers={"Authorization": "Basic notreally"}) 169 | assert response.status_code == 200, response.text 170 | assert response.json() == {"msg": "Create an account first"} 171 | -------------------------------------------------------------------------------- /tests/test_security_jwt_cookie.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from fastapi import FastAPI, Security 4 | from fastapi.testclient import TestClient 5 | 6 | from fastapi_jwt import JwtAccessCookie, JwtAuthorizationCredentials, JwtRefreshCookie, force_jwt_backend 7 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 8 | 9 | 10 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 11 | force_jwt_backend(jwt_backend) 12 | app = FastAPI() 13 | 14 | access_security = JwtAccessCookie(secret_key="secret_key") 15 | refresh_security = JwtRefreshCookie(secret_key="secret_key") 16 | 17 | @app.post("/auth") 18 | def auth(): 19 | subject = {"username": "username", "role": "user"} 20 | 21 | access_token = access_security.create_access_token(subject=subject) 22 | refresh_token = access_security.create_refresh_token(subject=subject) 23 | 24 | return {"access_token": access_token, "refresh_token": refresh_token} 25 | 26 | @app.post("/refresh") 27 | def refresh(credentials: JwtAuthorizationCredentials = Security(refresh_security)): 28 | access_token = refresh_security.create_access_token(subject=credentials.subject) 29 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject) 30 | 31 | return {"access_token": access_token, "refresh_token": refresh_token} 32 | 33 | @app.get("/users/me") 34 | def read_current_user( 35 | credentials: JwtAuthorizationCredentials = Security(access_security), 36 | ): 37 | return {"username": credentials["username"], "role": credentials["role"]} 38 | 39 | return TestClient(app) 40 | 41 | 42 | openapi_schema = { 43 | "openapi": "3.1.0", 44 | "info": {"title": "FastAPI", "version": "0.1.0"}, 45 | "paths": { 46 | "/auth": { 47 | "post": { 48 | "responses": { 49 | "200": { 50 | "description": "Successful Response", 51 | "content": {"application/json": {"schema": {}}}, 52 | } 53 | }, 54 | "summary": "Auth", 55 | "operationId": "auth_auth_post", 56 | }, 57 | }, 58 | "/refresh": { 59 | "post": { 60 | "responses": { 61 | "200": { 62 | "description": "Successful Response", 63 | "content": {"application/json": {"schema": {}}}, 64 | } 65 | }, 66 | "summary": "Refresh", 67 | "operationId": "refresh_refresh_post", 68 | "security": [{"JwtRefreshCookie": []}], 69 | } 70 | }, 71 | "/users/me": { 72 | "get": { 73 | "responses": { 74 | "200": { 75 | "description": "Successful Response", 76 | "content": {"application/json": {"schema": {}}}, 77 | } 78 | }, 79 | "summary": "Read Current User", 80 | "operationId": "read_current_user_users_me_get", 81 | "security": [{"JwtAccessCookie": []}], 82 | } 83 | }, 84 | }, 85 | "components": { 86 | "securitySchemes": { 87 | "JwtAccessCookie": { 88 | "type": "apiKey", 89 | "name": "access_token_cookie", 90 | "in": "cookie", 91 | }, 92 | "JwtRefreshCookie": { 93 | "type": "apiKey", 94 | "name": "refresh_token_cookie", 95 | "in": "cookie", 96 | }, 97 | } 98 | }, 99 | } 100 | 101 | 102 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 103 | client = create_example_client(jwt_backend) 104 | response = client.get("/openapi.json") 105 | assert response.status_code == 200, response.text 106 | assert response.json() == openapi_schema 107 | 108 | 109 | def test_security_jwt_auth(jwt_backend: Type[AbstractJWTBackend]): 110 | client = create_example_client(jwt_backend) 111 | response = client.post("/auth") 112 | assert response.status_code == 200, response.text 113 | 114 | 115 | def test_security_jwt_access_cookie(jwt_backend: Type[AbstractJWTBackend]): 116 | client = create_example_client(jwt_backend) 117 | access_token = client.post("/auth").json()["access_token"] 118 | 119 | response = client.get("/users/me", cookies={"access_token_cookie": access_token}) 120 | assert response.status_code == 200, response.text 121 | assert response.json() == {"username": "username", "role": "user"} 122 | 123 | 124 | def test_security_jwt_access_cookie_wrong(jwt_backend: Type[AbstractJWTBackend]): 125 | client = create_example_client(jwt_backend) 126 | response = client.get("/users/me", cookies={"access_token_cookie": "wrong_access_token_cookie"}) 127 | assert response.status_code == 401, response.text 128 | 129 | 130 | def test_security_jwt_access_cookie_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 131 | client = create_example_client(jwt_backend) 132 | client.cookies.clear() 133 | response = client.get("/users/me", cookies={}) 134 | assert response.status_code == 401, response.text 135 | assert response.json() == {"detail": "Credentials are not provided"} 136 | 137 | 138 | def test_security_jwt_refresh_cookie(jwt_backend: Type[AbstractJWTBackend]): 139 | client = create_example_client(jwt_backend) 140 | client.cookies.clear() 141 | refresh_token = client.post("/auth").json()["refresh_token"] 142 | 143 | response = client.post("/refresh", cookies={"refresh_token_cookie": refresh_token}) 144 | assert response.status_code == 200, response.text 145 | 146 | 147 | def test_security_jwt_refresh_cookie_wrong(jwt_backend: Type[AbstractJWTBackend]): 148 | client = create_example_client(jwt_backend) 149 | response = client.post("/refresh", cookies={"refresh_token_cookie": "wrong_refresh_token_cookie"}) 150 | assert response.status_code == 401, response.text 151 | 152 | 153 | def test_security_jwt_refresh_cookie_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 154 | client = create_example_client(jwt_backend) 155 | client.cookies.clear() 156 | response = client.post("/refresh", cookies={}) 157 | assert response.status_code == 401, response.text 158 | assert response.json() == {"detail": "Credentials are not provided"} 159 | -------------------------------------------------------------------------------- /tests/test_security_jwt_cookie_optional.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from fastapi import FastAPI, Security 4 | from fastapi.testclient import TestClient 5 | 6 | from fastapi_jwt import JwtAccessCookie, JwtAuthorizationCredentials, JwtRefreshCookie, force_jwt_backend 7 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 8 | 9 | 10 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 11 | force_jwt_backend(jwt_backend) 12 | app = FastAPI() 13 | 14 | access_security = JwtAccessCookie(secret_key="secret_key", auto_error=False) 15 | refresh_security = JwtRefreshCookie(secret_key="secret_key", auto_error=False) 16 | 17 | @app.post("/auth") 18 | def auth(): 19 | subject = {"username": "username", "role": "user"} 20 | 21 | access_token = access_security.create_access_token(subject=subject) 22 | refresh_token = access_security.create_refresh_token(subject=subject) 23 | 24 | return {"access_token": access_token, "refresh_token": refresh_token} 25 | 26 | @app.post("/refresh") 27 | def refresh( 28 | credentials: Optional[JwtAuthorizationCredentials] = Security(refresh_security), 29 | ): 30 | if credentials is None: 31 | return {"msg": "Create an account first"} 32 | 33 | access_token = refresh_security.create_access_token(subject=credentials.subject) 34 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject) 35 | 36 | return {"access_token": access_token, "refresh_token": refresh_token} 37 | 38 | @app.get("/users/me") 39 | def read_current_user( 40 | credentials: Optional[JwtAuthorizationCredentials] = Security(access_security), 41 | ): 42 | if credentials is None: 43 | return {"msg": "Create an account first"} 44 | 45 | return {"username": credentials["username"], "role": credentials["role"]} 46 | 47 | return TestClient(app) 48 | 49 | 50 | openapi_schema = { 51 | "openapi": "3.1.0", 52 | "info": {"title": "FastAPI", "version": "0.1.0"}, 53 | "paths": { 54 | "/auth": { 55 | "post": { 56 | "responses": { 57 | "200": { 58 | "description": "Successful Response", 59 | "content": {"application/json": {"schema": {}}}, 60 | } 61 | }, 62 | "summary": "Auth", 63 | "operationId": "auth_auth_post", 64 | }, 65 | }, 66 | "/refresh": { 67 | "post": { 68 | "responses": { 69 | "200": { 70 | "description": "Successful Response", 71 | "content": {"application/json": {"schema": {}}}, 72 | } 73 | }, 74 | "summary": "Refresh", 75 | "operationId": "refresh_refresh_post", 76 | "security": [{"JwtRefreshCookie": []}], 77 | } 78 | }, 79 | "/users/me": { 80 | "get": { 81 | "responses": { 82 | "200": { 83 | "description": "Successful Response", 84 | "content": {"application/json": {"schema": {}}}, 85 | } 86 | }, 87 | "summary": "Read Current User", 88 | "operationId": "read_current_user_users_me_get", 89 | "security": [{"JwtAccessCookie": []}], 90 | } 91 | }, 92 | }, 93 | "components": { 94 | "securitySchemes": { 95 | "JwtAccessCookie": { 96 | "type": "apiKey", 97 | "name": "access_token_cookie", 98 | "in": "cookie", 99 | }, 100 | "JwtRefreshCookie": { 101 | "type": "apiKey", 102 | "name": "refresh_token_cookie", 103 | "in": "cookie", 104 | }, 105 | } 106 | }, 107 | } 108 | 109 | 110 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 111 | client = create_example_client(jwt_backend) 112 | response = client.get("/openapi.json") 113 | assert response.status_code == 200, response.text 114 | assert response.json() == openapi_schema 115 | 116 | 117 | def test_security_jwt_auth(jwt_backend: Type[AbstractJWTBackend]): 118 | client = create_example_client(jwt_backend) 119 | response = client.post("/auth") 120 | assert response.status_code == 200, response.text 121 | 122 | 123 | def test_security_jwt_access_cookie(jwt_backend: Type[AbstractJWTBackend]): 124 | client = create_example_client(jwt_backend) 125 | client.cookies.clear() 126 | access_token = client.post("/auth").json()["access_token"] 127 | 128 | response = client.get("/users/me", cookies={"access_token_cookie": access_token}) 129 | assert response.status_code == 200, response.text 130 | assert response.json() == {"username": "username", "role": "user"} 131 | 132 | 133 | def test_security_jwt_access_cookie_wrong(jwt_backend: Type[AbstractJWTBackend]): 134 | client = create_example_client(jwt_backend) 135 | response = client.get("/users/me", cookies={"access_token_cookie": "wrong_access_token_cookie"}) 136 | assert response.status_code == 200, response.text 137 | assert response.json() == {"msg": "Create an account first"} 138 | 139 | 140 | def test_security_jwt_access_cookie_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 141 | client = create_example_client(jwt_backend) 142 | response = client.get("/users/me", cookies={}) 143 | assert response.status_code == 200, response.text 144 | assert response.json() == {"msg": "Create an account first"} 145 | 146 | 147 | def test_security_jwt_refresh_cookie(jwt_backend: Type[AbstractJWTBackend]): 148 | client = create_example_client(jwt_backend) 149 | refresh_token = client.post("/auth").json()["refresh_token"] 150 | 151 | response = client.post("/refresh", cookies={"refresh_token_cookie": refresh_token}) 152 | assert response.status_code == 200, response.text 153 | 154 | 155 | def test_security_jwt_refresh_cookie_wrong(jwt_backend: Type[AbstractJWTBackend]): 156 | client = create_example_client(jwt_backend) 157 | response = client.post("/refresh", cookies={"refresh_token_cookie": "wrong_refresh_token_cookie"}) 158 | assert response.status_code == 200, response.text 159 | assert response.json() == {"msg": "Create an account first"} 160 | 161 | 162 | def test_security_jwt_refresh_cookie_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 163 | client = create_example_client(jwt_backend) 164 | response = client.post("/refresh", cookies={}) 165 | assert response.status_code == 200, response.text 166 | assert response.json() == {"msg": "Create an account first"} 167 | -------------------------------------------------------------------------------- /tests/test_security_jwt_general.py: -------------------------------------------------------------------------------- 1 | from typing import Set, Type 2 | from uuid import uuid4 3 | 4 | from fastapi import FastAPI, Security 5 | from fastapi.testclient import TestClient 6 | from pytest_mock import MockerFixture 7 | 8 | from fastapi_jwt import JwtAccessBearer, JwtAuthorizationCredentials, JwtRefreshBearer, force_jwt_backend 9 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 10 | 11 | from .mock_datetime_utils import mock_now_for_backend 12 | 13 | 14 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 15 | force_jwt_backend(jwt_backend) 16 | app = FastAPI() 17 | 18 | access_security = JwtAccessBearer(secret_key="secret_key") 19 | refresh_security = JwtRefreshBearer.from_other(access_security) 20 | unique_identifiers_database: Set[str] = set() 21 | 22 | @app.post("/auth") 23 | def auth(): 24 | subject = {"username": "username", "role": "user"} 25 | unique_identifier = str(uuid4()) 26 | unique_identifiers_database.add(unique_identifier) 27 | 28 | access_token = access_security.create_access_token(subject=subject, unique_identifier=unique_identifier) 29 | refresh_token = access_security.create_refresh_token(subject=subject) 30 | 31 | return {"access_token": access_token, "refresh_token": refresh_token} 32 | 33 | @app.post("/refresh") 34 | def refresh(credentials: JwtAuthorizationCredentials = Security(refresh_security)): 35 | unique_identifier = str(uuid4()) 36 | unique_identifiers_database.add(unique_identifier) 37 | 38 | access_token = refresh_security.create_access_token( 39 | subject=credentials.subject, 40 | unique_identifier=unique_identifier, 41 | ) 42 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject) 43 | 44 | return {"access_token": access_token, "refresh_token": refresh_token} 45 | 46 | @app.get("/users/me") 47 | def read_current_user( 48 | credentials: JwtAuthorizationCredentials = Security(access_security), 49 | ): 50 | return {"username": credentials["username"], "role": credentials["role"]} 51 | 52 | @app.get("/auth/meta") 53 | def get_token_meta( 54 | credentials: JwtAuthorizationCredentials = Security(access_security), 55 | ): 56 | return {"jti": credentials.jti} 57 | 58 | return TestClient(app), unique_identifiers_database 59 | 60 | 61 | openapi_schema = { 62 | "openapi": "3.1.0", 63 | "info": {"title": "FastAPI", "version": "0.1.0"}, 64 | "paths": { 65 | "/auth": { 66 | "post": { 67 | "responses": { 68 | "200": { 69 | "description": "Successful Response", 70 | "content": {"application/json": {"schema": {}}}, 71 | } 72 | }, 73 | "summary": "Auth", 74 | "operationId": "auth_auth_post", 75 | } 76 | }, 77 | "/refresh": { 78 | "post": { 79 | "responses": { 80 | "200": { 81 | "description": "Successful Response", 82 | "content": {"application/json": {"schema": {}}}, 83 | } 84 | }, 85 | "summary": "Refresh", 86 | "operationId": "refresh_refresh_post", 87 | "security": [{"JwtRefreshBearer": []}], 88 | } 89 | }, 90 | "/users/me": { 91 | "get": { 92 | "responses": { 93 | "200": { 94 | "description": "Successful Response", 95 | "content": {"application/json": {"schema": {}}}, 96 | } 97 | }, 98 | "summary": "Read Current User", 99 | "operationId": "read_current_user_users_me_get", 100 | "security": [{"JwtAccessBearer": []}], 101 | } 102 | }, 103 | "/auth/meta": { 104 | "get": { 105 | "responses": { 106 | "200": { 107 | "description": "Successful Response", 108 | "content": {"application/json": {"schema": {}}}, 109 | } 110 | }, 111 | "summary": "Get Token Meta", 112 | "operationId": "get_token_meta_auth_meta_get", 113 | "security": [{"JwtAccessBearer": []}], 114 | } 115 | }, 116 | }, 117 | "components": { 118 | "securitySchemes": { 119 | "JwtAccessBearer": {"type": "http", "scheme": "bearer"}, 120 | "JwtRefreshBearer": {"type": "http", "scheme": "bearer"}, 121 | } 122 | }, 123 | } 124 | 125 | 126 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 127 | client, _ = create_example_client(jwt_backend) 128 | response = client.get("/openapi.json") 129 | assert response.status_code == 200, response.text 130 | assert response.json() == openapi_schema 131 | 132 | 133 | def test_security_jwt_access_token(jwt_backend: Type[AbstractJWTBackend]): 134 | client, _ = create_example_client(jwt_backend) 135 | access_token = client.post("/auth").json()["access_token"] 136 | 137 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 138 | assert response.status_code == 200, response.text 139 | assert response.json() == {"username": "username", "role": "user"} 140 | 141 | 142 | def test_security_jwt_access_token_wrong(jwt_backend: Type[AbstractJWTBackend]): 143 | client, _ = create_example_client(jwt_backend) 144 | response = client.get("/users/me", headers={"Authorization": "Bearer wrong_access_token"}) 145 | assert response.status_code == 401, response.text 146 | assert response.json()["detail"].startswith("Invalid token:") 147 | 148 | response = client.get("/users/me", headers={"Authorization": "Bearer wrong.access.token"}) 149 | assert response.status_code == 401, response.text 150 | assert response.json()["detail"].startswith("Invalid token:") 151 | 152 | 153 | def test_security_jwt_access_token_changed(jwt_backend: Type[AbstractJWTBackend]): 154 | client, _ = create_example_client(jwt_backend) 155 | access_token = client.post("/auth").json()["access_token"] 156 | 157 | access_token = access_token.split(".")[0] + ".wrong." + access_token.split(".")[-1] 158 | 159 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 160 | assert response.status_code == 401, response.text 161 | assert response.json()["detail"].startswith("Invalid token:") 162 | 163 | 164 | def test_security_jwt_access_token_expiration(mocker: MockerFixture, jwt_backend): 165 | client, _ = create_example_client(jwt_backend) 166 | access_token = client.post("/auth").json()["access_token"] 167 | 168 | mock_now_for_backend(mocker, jwt_backend, minutes=3) # 3 min left 169 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 170 | assert response.status_code == 200, response.text 171 | 172 | mock_now_for_backend(mocker, jwt_backend, days=42) # 42 days left 173 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 174 | assert response.status_code == 401, response.text 175 | assert response.json()["detail"].startswith("Token time expired:") 176 | 177 | 178 | def test_security_jwt_refresh_token(jwt_backend: Type[AbstractJWTBackend]): 179 | client, _ = create_example_client(jwt_backend) 180 | refresh_token = client.post("/auth").json()["refresh_token"] 181 | 182 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 183 | assert response.status_code == 200, response.text 184 | 185 | 186 | def test_security_jwt_refresh_token_wrong(jwt_backend: Type[AbstractJWTBackend]): 187 | client, _ = create_example_client(jwt_backend) 188 | response = client.post("/refresh", headers={"Authorization": "Bearer wrong_refresh_token"}) 189 | assert response.status_code == 401, response.text 190 | assert response.json()["detail"].startswith("Invalid token:") 191 | 192 | response = client.post("/refresh", headers={"Authorization": "Bearer wrong.refresh.token"}) 193 | assert response.status_code == 401, response.text 194 | assert response.json()["detail"].startswith("Invalid token:") 195 | 196 | 197 | def test_security_jwt_refresh_token_using_access_token(jwt_backend: Type[AbstractJWTBackend]): 198 | client, _ = create_example_client(jwt_backend) 199 | tokens = client.post("/auth").json() 200 | access_token, refresh_token = tokens["access_token"], tokens["refresh_token"] 201 | assert access_token != refresh_token 202 | 203 | response = client.post("/refresh", headers={"Authorization": f"Bearer {access_token}"}) 204 | assert response.status_code == 401, response.text 205 | assert response.json()["detail"].startswith("Invalid token: 'type' is not 'refresh'") 206 | 207 | 208 | def test_security_jwt_refresh_token_changed(jwt_backend: Type[AbstractJWTBackend]): 209 | client, _ = create_example_client(jwt_backend) 210 | refresh_token = client.post("/auth").json()["refresh_token"] 211 | 212 | refresh_token = refresh_token.split(".")[0] + ".wrong." + refresh_token.split(".")[-1] 213 | 214 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 215 | assert response.status_code == 401, response.text 216 | assert response.json()["detail"].startswith("Invalid token:") 217 | 218 | 219 | def test_security_jwt_refresh_token_expired(mocker: MockerFixture, jwt_backend): 220 | client, _ = create_example_client(jwt_backend) 221 | refresh_token = client.post("/auth").json()["refresh_token"] 222 | 223 | mock_now_for_backend(mocker, jwt_backend, days=42) # 42 days left 224 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 225 | assert response.status_code == 401, response.text 226 | assert response.json()["detail"].startswith("Token time expired:") 227 | 228 | 229 | def test_security_jwt_custom_jti(jwt_backend: Type[AbstractJWTBackend]): 230 | client, unique_identifiers_database = create_example_client(jwt_backend) 231 | access_token = client.post("/auth").json()["access_token"] 232 | 233 | response = client.get("/auth/meta", headers={"Authorization": f"Bearer {access_token}"}) 234 | assert response.status_code == 200, response.text 235 | assert response.json()["jti"] in unique_identifiers_database 236 | -------------------------------------------------------------------------------- /tests/test_security_jwt_general_optional.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Set, Type 2 | from uuid import uuid4 3 | 4 | from fastapi import FastAPI, Security 5 | from fastapi.testclient import TestClient 6 | from pytest_mock import MockerFixture 7 | 8 | from fastapi_jwt import JwtAccessBearer, JwtAuthorizationCredentials, JwtRefreshBearer, force_jwt_backend 9 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 10 | 11 | from .mock_datetime_utils import mock_now_for_backend 12 | 13 | 14 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 15 | force_jwt_backend(jwt_backend) 16 | app = FastAPI() 17 | 18 | access_security = JwtAccessBearer(secret_key="secret_key", auto_error=False) 19 | refresh_security = JwtRefreshBearer.from_other(access_security) 20 | unique_identifiers_database: Set[str] = set() 21 | 22 | @app.post("/auth") 23 | def auth(): 24 | subject = {"username": "username", "role": "user"} 25 | unique_identifier = str(uuid4()) 26 | unique_identifiers_database.add(unique_identifier) 27 | 28 | access_token = access_security.create_access_token(subject=subject, unique_identifier=unique_identifier) 29 | refresh_token = access_security.create_refresh_token(subject=subject) 30 | 31 | return {"access_token": access_token, "refresh_token": refresh_token} 32 | 33 | @app.post("/refresh") 34 | def refresh( 35 | credentials: Optional[JwtAuthorizationCredentials] = Security(refresh_security), 36 | ): 37 | if credentials is None: 38 | return {"msg": "Create an account first"} 39 | 40 | unique_identifier = str(uuid4()) 41 | unique_identifiers_database.add(unique_identifier) 42 | 43 | access_token = refresh_security.create_access_token( 44 | subject=credentials.subject, 45 | unique_identifier=unique_identifier, 46 | ) 47 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject) 48 | 49 | return {"access_token": access_token, "refresh_token": refresh_token} 50 | 51 | @app.get("/users/me") 52 | def read_current_user( 53 | credentials: Optional[JwtAuthorizationCredentials] = Security(access_security), 54 | ): 55 | if credentials is None: 56 | return {"msg": "Create an account first"} 57 | return {"username": credentials["username"], "role": credentials["role"]} 58 | 59 | @app.get("/auth/meta") 60 | def get_token_meta( 61 | credentials: JwtAuthorizationCredentials = Security(access_security), 62 | ): 63 | if credentials is None: 64 | return {"msg": "Create an account first"} 65 | return {"jti": credentials.jti} 66 | 67 | return TestClient(app), unique_identifiers_database 68 | 69 | 70 | openapi_schema = { 71 | "openapi": "3.1.0", 72 | "info": {"title": "FastAPI", "version": "0.1.0"}, 73 | "paths": { 74 | "/auth": { 75 | "post": { 76 | "responses": { 77 | "200": { 78 | "description": "Successful Response", 79 | "content": {"application/json": {"schema": {}}}, 80 | } 81 | }, 82 | "summary": "Auth", 83 | "operationId": "auth_auth_post", 84 | } 85 | }, 86 | "/refresh": { 87 | "post": { 88 | "responses": { 89 | "200": { 90 | "description": "Successful Response", 91 | "content": {"application/json": {"schema": {}}}, 92 | } 93 | }, 94 | "summary": "Refresh", 95 | "operationId": "refresh_refresh_post", 96 | "security": [{"JwtRefreshBearer": []}], 97 | } 98 | }, 99 | "/users/me": { 100 | "get": { 101 | "responses": { 102 | "200": { 103 | "description": "Successful Response", 104 | "content": {"application/json": {"schema": {}}}, 105 | } 106 | }, 107 | "summary": "Read Current User", 108 | "operationId": "read_current_user_users_me_get", 109 | "security": [{"JwtAccessBearer": []}], 110 | } 111 | }, 112 | "/auth/meta": { 113 | "get": { 114 | "responses": { 115 | "200": { 116 | "description": "Successful Response", 117 | "content": {"application/json": {"schema": {}}}, 118 | } 119 | }, 120 | "summary": "Get Token Meta", 121 | "operationId": "get_token_meta_auth_meta_get", 122 | "security": [{"JwtAccessBearer": []}], 123 | } 124 | }, 125 | }, 126 | "components": { 127 | "securitySchemes": { 128 | "JwtAccessBearer": {"type": "http", "scheme": "bearer"}, 129 | "JwtRefreshBearer": {"type": "http", "scheme": "bearer"}, 130 | } 131 | }, 132 | } 133 | 134 | 135 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 136 | client, _ = create_example_client(jwt_backend) 137 | response = client.get("/openapi.json") 138 | assert response.status_code == 200, response.text 139 | assert response.json() == openapi_schema 140 | 141 | 142 | def test_security_jwt_access_token(jwt_backend: Type[AbstractJWTBackend]): 143 | client, _ = create_example_client(jwt_backend) 144 | access_token = client.post("/auth").json()["access_token"] 145 | 146 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 147 | assert response.status_code == 200, response.text 148 | assert response.json() == {"username": "username", "role": "user"} 149 | 150 | 151 | def test_security_jwt_access_token_wrong(jwt_backend: Type[AbstractJWTBackend]): 152 | client, _ = create_example_client(jwt_backend) 153 | response = client.get("/users/me", headers={"Authorization": "Bearer wrong_access_token"}) 154 | assert response.status_code == 200, response.text 155 | assert response.json() == {"msg": "Create an account first"} 156 | 157 | response = client.get("/users/me", headers={"Authorization": "Bearer wrong.access.token"}) 158 | assert response.status_code == 200, response.text 159 | assert response.json() == {"msg": "Create an account first"} 160 | 161 | 162 | def test_security_jwt_access_token_changed(jwt_backend: Type[AbstractJWTBackend]): 163 | client, _ = create_example_client(jwt_backend) 164 | access_token = client.post("/auth").json()["access_token"] 165 | 166 | access_token = access_token.split(".")[0] + ".wrong." + access_token.split(".")[-1] 167 | 168 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 169 | assert response.status_code == 200, response.text 170 | assert response.json() == {"msg": "Create an account first"} 171 | 172 | 173 | def test_security_jwt_access_token_expiration(mocker: MockerFixture, jwt_backend): 174 | client, _ = create_example_client(jwt_backend) 175 | access_token = client.post("/auth").json()["access_token"] 176 | 177 | mock_now_for_backend(mocker, jwt_backend, minutes=3) # 3 min left 178 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 179 | assert response.status_code == 200, response.text 180 | assert response.json() == {"username": "username", "role": "user"} 181 | 182 | mock_now_for_backend(mocker, jwt_backend, days=42) # 42 days left 183 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 184 | assert response.status_code == 200, response.text 185 | assert response.json() == {"msg": "Create an account first"} 186 | 187 | 188 | def test_security_jwt_refresh_token(jwt_backend: Type[AbstractJWTBackend]): 189 | client, _ = create_example_client(jwt_backend) 190 | refresh_token = client.post("/auth").json()["refresh_token"] 191 | 192 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 193 | assert response.status_code == 200, response.text 194 | assert "msg" not in response.json() 195 | 196 | 197 | def test_security_jwt_refresh_token_wrong(jwt_backend: Type[AbstractJWTBackend]): 198 | client, _ = create_example_client(jwt_backend) 199 | response = client.post("/refresh", headers={"Authorization": "Bearer wrong_refresh_token"}) 200 | assert response.status_code == 200, response.text 201 | assert response.json() == {"msg": "Create an account first"} 202 | 203 | response = client.post("/refresh", headers={"Authorization": "Bearer wrong.refresh.token"}) 204 | assert response.status_code == 200, response.text 205 | assert response.json() == {"msg": "Create an account first"} 206 | 207 | 208 | def test_security_jwt_refresh_token_using_access_token(jwt_backend: Type[AbstractJWTBackend]): 209 | client, _ = create_example_client(jwt_backend) 210 | tokens = client.post("/auth").json() 211 | access_token, refresh_token = tokens["access_token"], tokens["refresh_token"] 212 | assert access_token != refresh_token 213 | 214 | response = client.post("/refresh", headers={"Authorization": f"Bearer {access_token}"}) 215 | assert response.status_code == 200, response.text 216 | assert response.json() == {"msg": "Create an account first"} 217 | 218 | 219 | def test_security_jwt_refresh_token_changed(jwt_backend: Type[AbstractJWTBackend]): 220 | client, _ = create_example_client(jwt_backend) 221 | refresh_token = client.post("/auth").json()["refresh_token"] 222 | 223 | refresh_token = refresh_token.split(".")[0] + ".wrong." + refresh_token.split(".")[-1] 224 | 225 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 226 | assert response.status_code == 200, response.text 227 | assert response.json() == {"msg": "Create an account first"} 228 | 229 | 230 | def test_security_jwt_refresh_token_expired(mocker: MockerFixture, jwt_backend): 231 | client, _ = create_example_client(jwt_backend) 232 | refresh_token = client.post("/auth").json()["refresh_token"] 233 | 234 | mock_now_for_backend(mocker, jwt_backend, days=42) # 42 days left 235 | response = client.post("/refresh", headers={"Authorization": f"Bearer {refresh_token}"}) 236 | assert response.status_code == 200, response.text 237 | assert response.json() == {"msg": "Create an account first"} 238 | 239 | 240 | def test_security_jwt_custom_jti(jwt_backend: Type[AbstractJWTBackend]): 241 | client, unique_identifiers_database = create_example_client(jwt_backend) 242 | access_token = client.post("/auth").json()["access_token"] 243 | 244 | response = client.get("/auth/meta", headers={"Authorization": f"Bearer {access_token}"}) 245 | assert response.status_code == 200, response.text 246 | assert response.json()["jti"] in unique_identifiers_database 247 | -------------------------------------------------------------------------------- /tests/test_security_jwt_multiple_places.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from fastapi import FastAPI, Security 4 | from fastapi.testclient import TestClient 5 | 6 | from fastapi_jwt import JwtAccessBearerCookie, JwtAuthorizationCredentials, JwtRefreshBearerCookie, force_jwt_backend 7 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 8 | 9 | 10 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 11 | force_jwt_backend(jwt_backend) 12 | app = FastAPI() 13 | 14 | access_security = JwtAccessBearerCookie(secret_key="secret_key") 15 | refresh_security = JwtRefreshBearerCookie(secret_key="secret_key") 16 | 17 | @app.post("/auth") 18 | def auth(): 19 | subject = {"username": "username", "role": "user"} 20 | 21 | access_token = access_security.create_access_token(subject=subject) 22 | refresh_token = access_security.create_refresh_token(subject=subject) 23 | 24 | return {"access_token": access_token, "refresh_token": refresh_token} 25 | 26 | @app.post("/refresh") 27 | def refresh(credentials: JwtAuthorizationCredentials = Security(refresh_security)): 28 | access_token = refresh_security.create_access_token(subject=credentials.subject) 29 | refresh_token = refresh_security.create_refresh_token(subject=credentials.subject) 30 | 31 | return {"access_token": access_token, "refresh_token": refresh_token} 32 | 33 | @app.get("/users/me") 34 | def read_current_user( 35 | credentials: JwtAuthorizationCredentials = Security(access_security), 36 | ): 37 | return {"username": credentials["username"], "role": credentials["role"]} 38 | 39 | return TestClient(app) 40 | 41 | 42 | openapi_schema = { 43 | "openapi": "3.1.0", 44 | "info": {"title": "FastAPI", "version": "0.1.0"}, 45 | "paths": { 46 | "/auth": { 47 | "post": { 48 | "responses": { 49 | "200": { 50 | "description": "Successful Response", 51 | "content": {"application/json": {"schema": {}}}, 52 | } 53 | }, 54 | "summary": "Auth", 55 | "operationId": "auth_auth_post", 56 | } 57 | }, 58 | "/refresh": { 59 | "post": { 60 | "responses": { 61 | "200": { 62 | "description": "Successful Response", 63 | "content": {"application/json": {"schema": {}}}, 64 | } 65 | }, 66 | "summary": "Refresh", 67 | "operationId": "refresh_refresh_post", 68 | "security": [{"JwtRefreshBearer": []}, {"JwtRefreshCookie": []}], 69 | } 70 | }, 71 | "/users/me": { 72 | "get": { 73 | "responses": { 74 | "200": { 75 | "description": "Successful Response", 76 | "content": {"application/json": {"schema": {}}}, 77 | } 78 | }, 79 | "summary": "Read Current User", 80 | "operationId": "read_current_user_users_me_get", 81 | "security": [{"JwtAccessBearer": []}, {"JwtAccessCookie": []}], 82 | } 83 | }, 84 | }, 85 | "components": { 86 | "securitySchemes": { 87 | "JwtAccessBearer": {"type": "http", "scheme": "bearer"}, 88 | "JwtAccessCookie": { 89 | "type": "apiKey", 90 | "name": "access_token_cookie", 91 | "in": "cookie", 92 | }, 93 | "JwtRefreshBearer": {"type": "http", "scheme": "bearer"}, 94 | "JwtRefreshCookie": { 95 | "type": "apiKey", 96 | "name": "refresh_token_cookie", 97 | "in": "cookie", 98 | }, 99 | } 100 | }, 101 | } 102 | 103 | 104 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 105 | client = create_example_client(jwt_backend) 106 | response = client.get("/openapi.json") 107 | assert response.status_code == 200, response.text 108 | assert response.json() == openapi_schema 109 | 110 | 111 | def test_security_jwt_access_both_correct(jwt_backend: Type[AbstractJWTBackend]): 112 | client = create_example_client(jwt_backend) 113 | access_token = client.post("/auth").json()["access_token"] 114 | 115 | response = client.get( 116 | "/users/me", 117 | cookies={"access_token_cookie": access_token}, 118 | headers={"Authorization": f"Bearer {access_token}"}, 119 | ) 120 | assert response.status_code == 200, response.text 121 | assert response.json() == {"username": "username", "role": "user"} 122 | 123 | 124 | def test_security_jwt_access_only_cookie(jwt_backend: Type[AbstractJWTBackend]): 125 | client = create_example_client(jwt_backend) 126 | access_token = client.post("/auth").json()["access_token"] 127 | 128 | response = client.get("/users/me", cookies={"access_token_cookie": access_token}) 129 | assert response.status_code == 200, response.text 130 | assert response.json() == {"username": "username", "role": "user"} 131 | 132 | 133 | def test_security_jwt_access_only_bearer(jwt_backend: Type[AbstractJWTBackend]): 134 | client = create_example_client(jwt_backend) 135 | access_token = client.post("/auth").json()["access_token"] 136 | 137 | response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) 138 | assert response.status_code == 200, response.text 139 | assert response.json() == {"username": "username", "role": "user"} 140 | 141 | 142 | def test_security_jwt_access_bearer_wrong_cookie_correct(jwt_backend: Type[AbstractJWTBackend]): 143 | client = create_example_client(jwt_backend) 144 | access_token = client.post("/auth").json()["access_token"] 145 | 146 | response = client.get( 147 | "/users/me", 148 | headers={"Authorization": "Bearer wrong_access_token"}, 149 | cookies={"access_token_cookie": access_token}, 150 | ) 151 | assert response.status_code == 401, response.text 152 | assert response.json()["detail"].startswith("Invalid token:") 153 | 154 | 155 | def test_security_jwt_access_bearer_correct_cookie_wrong(jwt_backend: Type[AbstractJWTBackend]): 156 | client = create_example_client(jwt_backend) 157 | access_token = client.post("/auth").json()["access_token"] 158 | 159 | response = client.get( 160 | "/users/me", 161 | headers={"Authorization": f"Bearer {access_token}"}, 162 | cookies={"access_token_cookie": "wrong_access_token_cookie"}, 163 | ) 164 | assert response.status_code == 200, response.text 165 | assert response.json() == {"username": "username", "role": "user"} 166 | 167 | 168 | def test_security_jwt_access_both_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 169 | client = create_example_client(jwt_backend) 170 | response = client.get("/users/me") 171 | assert response.status_code == 401, response.text 172 | assert response.json() == {"detail": "Credentials are not provided"} 173 | 174 | 175 | def test_security_jwt_refresh_only_cookie(jwt_backend: Type[AbstractJWTBackend]): 176 | client = create_example_client(jwt_backend) 177 | refresh_token = client.post("/auth").json()["refresh_token"] 178 | 179 | response = client.post("/refresh", cookies={"refresh_token_cookie": refresh_token}) 180 | assert response.status_code == 200, response.text 181 | 182 | 183 | def test_security_jwt_refresh_bearer_correct_cookie_wrong(jwt_backend: Type[AbstractJWTBackend]): 184 | client = create_example_client(jwt_backend) 185 | refresh_token = client.post("/auth").json()["refresh_token"] 186 | 187 | response = client.post( 188 | "/refresh", 189 | headers={"Authorization": f"Bearer {refresh_token}"}, 190 | cookies={"refresh_token_cookie": "wrong_refresh_token_cookie"}, 191 | ) 192 | assert response.status_code == 200, response.text 193 | 194 | 195 | def test_security_jwt_refresh_bearer_wrong_cookie_correct(jwt_backend: Type[AbstractJWTBackend]): 196 | client = create_example_client(jwt_backend) 197 | refresh_token = client.post("/auth").json()["refresh_token"] 198 | 199 | response = client.post( 200 | "/refresh", 201 | headers={"Authorization": "Bearer wrong_refresh_token_cookie"}, 202 | cookies={"refresh_token_cookie": refresh_token}, 203 | ) 204 | assert response.status_code == 401, response.text 205 | assert response.json()["detail"].startswith("Invalid token:") 206 | 207 | 208 | def test_security_jwt_refresh_cookie_wrong_using_access_token(jwt_backend: Type[AbstractJWTBackend]): 209 | client = create_example_client(jwt_backend) 210 | tokens = client.post("/auth").json() 211 | access_token, refresh_token = tokens["access_token"], tokens["refresh_token"] 212 | assert access_token != refresh_token 213 | 214 | response = client.post("/refresh", cookies={"refresh_token_cookie": access_token}) 215 | assert response.status_code == 401, response.text 216 | assert response.json()["detail"].startswith("Invalid token: 'type' is not 'refresh'") 217 | 218 | 219 | def test_security_jwt_refresh_both_no_credentials(jwt_backend: Type[AbstractJWTBackend]): 220 | client = create_example_client(jwt_backend) 221 | response = client.post("/refresh") 222 | assert response.status_code == 401, response.text 223 | assert response.json() == {"detail": "Credentials are not provided"} 224 | -------------------------------------------------------------------------------- /tests/test_security_jwt_set_cookie.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from fastapi import FastAPI, Response 4 | from fastapi.testclient import TestClient 5 | 6 | from fastapi_jwt import JwtAccessCookie, JwtRefreshCookie, force_jwt_backend 7 | from fastapi_jwt.jwt_backends import AbstractJWTBackend 8 | 9 | 10 | def create_example_client(jwt_backend: Type[AbstractJWTBackend]): 11 | force_jwt_backend(jwt_backend) 12 | app = FastAPI() 13 | 14 | access_security = JwtAccessCookie(secret_key="secret_key") 15 | refresh_security = JwtRefreshCookie(secret_key="secret_key") 16 | 17 | @app.post("/auth") 18 | def auth(response: Response): 19 | subject = {"username": "username", "role": "user"} 20 | 21 | access_token = access_security.create_access_token(subject=subject) 22 | refresh_token = access_security.create_refresh_token(subject=subject) 23 | 24 | access_security.set_access_cookie(response, access_token) 25 | refresh_security.set_refresh_cookie(response, refresh_token) 26 | 27 | return {"access_token": access_token, "refresh_token": refresh_token} 28 | 29 | @app.delete("/auth") 30 | def logout(response: Response): 31 | access_security.unset_access_cookie(response) 32 | refresh_security.unset_refresh_cookie(response) 33 | 34 | return {"msg": "Successful logout"} 35 | 36 | return TestClient(app) 37 | 38 | 39 | openapi_schema = { 40 | "openapi": "3.1.0", 41 | "info": {"title": "FastAPI", "version": "0.1.0"}, 42 | "paths": { 43 | "/auth": { 44 | "post": { 45 | "responses": { 46 | "200": { 47 | "description": "Successful Response", 48 | "content": {"application/json": {"schema": {}}}, 49 | } 50 | }, 51 | "summary": "Auth", 52 | "operationId": "auth_auth_post", 53 | }, 54 | "delete": { 55 | "responses": { 56 | "200": { 57 | "description": "Successful Response", 58 | "content": {"application/json": {"schema": {}}}, 59 | } 60 | }, 61 | "summary": "Logout", 62 | "operationId": "logout_auth_delete", 63 | }, 64 | } 65 | }, 66 | } 67 | 68 | 69 | def test_openapi_schema(jwt_backend: Type[AbstractJWTBackend]): 70 | client = create_example_client(jwt_backend) 71 | response = client.get("/openapi.json") 72 | assert response.status_code == 200, response.text 73 | assert response.json() == openapi_schema 74 | 75 | 76 | def test_security_jwt_auth(jwt_backend: Type[AbstractJWTBackend]): 77 | client = create_example_client(jwt_backend) 78 | response = client.post("/auth") 79 | assert response.status_code == 200, response.text 80 | 81 | assert "access_token_cookie" in response.cookies 82 | assert response.cookies["access_token_cookie"] == response.json()["access_token"] 83 | assert "refresh_token_cookie" in response.cookies 84 | assert response.cookies["refresh_token_cookie"] == response.json()["refresh_token"] 85 | 86 | 87 | def test_security_jwt_logout(jwt_backend: Type[AbstractJWTBackend]): 88 | client = create_example_client(jwt_backend) 89 | response = client.delete("/auth") 90 | assert response.status_code == 200, response.text 91 | 92 | assert "access_token_cookie" in response.headers["set-cookie"] 93 | assert 'access_token_cookie=""; Max-Age=-1;' in response.headers["set-cookie"] 94 | assert "refresh_token_cookie" in response.headers["set-cookie"] 95 | assert 'refresh_token_cookie=""; HttpOnly; Max-Age=-1' in response.headers["set-cookie"] 96 | # assert "access_token_cookie" not in response.cookies 97 | # assert response.cookies["access_token_cookie"].max_age == -1 98 | # assert "refresh_token_cookie" not in response.cookies 99 | # assert response.cookies["refresh_token_cookie"].max_age == -1 100 | --------------------------------------------------------------------------------