├── tests ├── __init__.py ├── test_init.py ├── test_pkce.py ├── jwks.json ├── conftest.py ├── test_integrations_cli.py ├── test_integrations_flask.py ├── test_integrations_fastapi.py └── test_client.py ├── fief_client ├── py.typed ├── integrations │ ├── __init__.py │ ├── flask.py │ ├── fastapi.py │ └── cli.py ├── crypto.py ├── __init__.py ├── pkce.py └── client.py ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.md └── workflows │ ├── docs.yml │ ├── build.yml │ └── codeql-analysis.yml ├── .editorconfig ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── .gitignore └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief_client/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief_client/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | """Modules containing helpers and shortcuts for popular frameworks.""" 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: 🤔 I have a question 3 | url: https://github.com/orgs/fief-dev/discussions/new?category=q-a&labels=integration-python 4 | about: If you have any question about Fief that's not clearly a bug, please open a discussion first. 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.py] 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | from contextlib import nullcontext as does_not_raise 2 | 3 | 4 | def test_exports(): 5 | with does_not_raise(): 6 | from fief_client import ( 7 | Fief, 8 | FiefAsync, 9 | FiefError, 10 | FiefIdTokenInvalid, 11 | FiefTokenResponse, 12 | FiefUserInfo, 13 | ) 14 | -------------------------------------------------------------------------------- /tests/test_pkce.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fief_client.pkce import Method, get_code_challenge, get_code_verifier 4 | 5 | 6 | def test_get_code_verifier(): 7 | code = get_code_verifier() 8 | assert isinstance(code, str) 9 | assert len(code) == 128 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "code,method", 14 | [ 15 | ("A" * 128, "plain"), 16 | ("A" * 128, "S256"), 17 | ], 18 | ) 19 | def test_code_challenge(code: str, method: Method): 20 | challenge = get_code_challenge(code, method) 21 | assert isinstance(challenge, str) 22 | 23 | if method == "plain": 24 | assert challenge == code 25 | elif method == "S256": 26 | assert len(challenge) == 43 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🪲 I found a bug 3 | about: I'm sure it's a bug and I want to report it. 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## To Reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Configuration 27 | 28 | * Python version: 29 | * Fief client version: 30 | 31 | * Fief Cloud or self-hosted: Cloud 32 | * If self-hosted, Fief version: 33 | 34 | ## Additional context 35 | 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /fief_client/crypto.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import secrets 4 | 5 | 6 | def get_validation_hash(value: str) -> str: 7 | """ 8 | Return the validation hash of a value. 9 | 10 | Useful to check the validity `c_hash` and `at_hash` claims. 11 | """ 12 | hasher = hashlib.sha256() 13 | hasher.update(value.encode("utf-8")) 14 | hash = hasher.digest() 15 | 16 | half_hash = hash[0 : int(len(hash) / 2)] 17 | # Remove the Base64 padding "==" at the end 18 | base64_hash = base64.urlsafe_b64encode(half_hash)[:-2] 19 | 20 | return base64_hash.decode("utf-8") 21 | 22 | 23 | def is_valid_hash(value: str, hash: str) -> bool: 24 | """ 25 | Check if a hash corresponds to the provided value. 26 | 27 | Useful to check the validity `c_hash` and `at_hash` claims. 28 | """ 29 | value_hash = get_validation_hash(value) 30 | return secrets.compare_digest(value_hash, hash) 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "python.analysis.autoImportCompletions": true, 4 | "python.envFile": "${workspaceFolder}/.env.testing", 5 | "python.terminal.activateEnvironment": true, 6 | "python.terminal.activateEnvInCurrentTerminal": true, 7 | "python.testing.unittestEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "editor.rulers": [88], 10 | "python.defaultInterpreterPath": "${workspaceFolder}/.hatch/fief-python/bin/python", 11 | "python.testing.pytestPath": "${workspaceFolder}/.hatch/fief-python/bin/pytest", 12 | "python.testing.cwd": "${workspaceFolder}", 13 | "python.testing.pytestArgs": ["-n 0", "--no-cov"], 14 | "[python]": { 15 | "editor.formatOnSave": true, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll": "explicit", 18 | "source.organizeImports": "explicit" 19 | }, 20 | "editor.defaultFormatter": "charliermarsh.ruff" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /fief_client/__init__.py: -------------------------------------------------------------------------------- 1 | "Fief client for Python." 2 | 3 | from fief_client.client import ( 4 | Fief, 5 | FiefAccessTokenACRTooLow, 6 | FiefAccessTokenExpired, 7 | FiefAccessTokenInfo, 8 | FiefAccessTokenInvalid, 9 | FiefAccessTokenMissingPermission, 10 | FiefAccessTokenMissingScope, 11 | FiefACR, 12 | FiefAsync, 13 | FiefError, 14 | FiefIdTokenInvalid, 15 | FiefRequestError, 16 | FiefTokenResponse, 17 | FiefUserInfo, 18 | ) 19 | 20 | __version__ = "0.20.0" 21 | 22 | __all__ = [ 23 | "Fief", 24 | "FiefACR", 25 | "FiefAsync", 26 | "FiefTokenResponse", 27 | "FiefAccessTokenInfo", 28 | "FiefUserInfo", 29 | "FiefError", 30 | "FiefAccessTokenACRTooLow", 31 | "FiefAccessTokenExpired", 32 | "FiefAccessTokenMissingPermission", 33 | "FiefAccessTokenMissingScope", 34 | "FiefAccessTokenInvalid", 35 | "FiefIdTokenInvalid", 36 | "FiefRequestError", 37 | "crypto", 38 | "pkce", 39 | "integrations", 40 | ] 41 | -------------------------------------------------------------------------------- /fief_client/pkce.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import secrets 4 | from typing import Literal 5 | 6 | 7 | def get_code_verifier() -> str: 8 | """ 9 | Generate a code verifier suitable for PKCE. 10 | """ 11 | return secrets.token_urlsafe(96) 12 | 13 | 14 | Method = Literal["plain", "S256"] 15 | 16 | 17 | def get_code_challenge(code: str, method: Method = "S256") -> str: 18 | """ 19 | Generate the PKCE code challenge for the given code and method. 20 | 21 | :param code: The code to generate the challenge for. 22 | :param method: The method to use for generating the challenge. Either `plain` or `S256`. 23 | """ 24 | if method == "plain": 25 | return code 26 | 27 | if method == "S256": 28 | hasher = hashlib.sha256() 29 | hasher.update(code.encode("ascii")) 30 | digest = hasher.digest() 31 | b64_digest = base64.urlsafe_b64encode(digest).decode("ascii") 32 | return b64_digest[:-1] # Remove the padding "=" at the end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 François Voron 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 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | # Default to bash 20 | defaults: 21 | run: 22 | shell: bash 23 | 24 | jobs: 25 | build: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | - name: Set up Python 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: "3.10" 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install hatch 38 | hatch env create docs 39 | - name: Build 40 | run: hatch --env docs run build 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v3 43 | with: 44 | path: ./docs/_build 45 | 46 | deploy: 47 | environment: 48 | name: github-pages 49 | url: ${{ steps.deployment.outputs.page_url }} 50 | runs-on: ubuntu-latest 51 | needs: build 52 | steps: 53 | - name: Deploy to GitHub Pages 54 | id: deployment 55 | uses: actions/deploy-pages@v4 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fief client for Python 2 | 3 | [![build](https://github.com/fief-dev/fief-python/workflows/Build/badge.svg)](https://github.com/fief-dev/fief-python/actions) 4 | [![codecov](https://codecov.io/gh/fief-dev/fief-python/branch/main/graph/badge.svg)](https://codecov.io/gh/fief-dev/fief-python) 5 | [![PyPI version](https://badge.fury.io/py/fief-client.svg)](https://badge.fury.io/py/fief-client) 6 | 7 | > [!IMPORTANT] 8 | > Fief, as you know it today, is wrapping up its current chapter. 9 | > We're working on a completely new and exciting vision for the future! 10 | > 11 | > This means we won't be adding new features or fixing bugs in this codebase anymore. 12 | > Thank you so much for being part of the journey so far — your support has meant the world to us. 13 | > 14 | > **Stay tuned — we can't wait to show you what's next! 🚀** 15 | 16 | ## Installation 17 | 18 | ``` 19 | pip install fief-client 20 | ``` 21 | 22 | ## Getting started 23 | 24 | - Official website: [https://www.fief.dev](https://www.fief.dev) 25 | - Documentation: [https://docs.fief.dev](https://docs.fief.dev) 26 | 27 | ## Contributing 28 | 29 | All contributions to improve the project are welcome! In particular, bug and documentation fixes are really appreciated. 30 | 31 | For new features and larger improvements, we kindly ask you to [**open a discussion first**](https://github.com/orgs/fief-dev/discussions/new?category=ideas) about your idea, what motivates it and how you plan to implement it **before you start working**. It'll avoid frustration on both sides if we decide not to integrate your code in the project. 32 | 33 | ## License 34 | 35 | MIT 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python_version: ['3.9', '3.10', '3.11', '3.12'] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python_version }} 19 | - name: Install dependencies 20 | shell: bash 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install hatch 24 | hatch env create 25 | - name: Lint and typecheck 26 | run: | 27 | hatch run lint-check 28 | - name: Test 29 | run: | 30 | hatch run test-cov-xml 31 | - uses: codecov/codecov-action@v4 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | fail_ci_if_error: true 35 | verbose: true 36 | 37 | release: 38 | runs-on: ubuntu-latest 39 | needs: test 40 | if: startsWith(github.ref, 'refs/tags/') 41 | 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Set up Python 45 | uses: actions/setup-python@v4 46 | with: 47 | python-version: '3.9' 48 | - name: Install dependencies 49 | shell: bash 50 | run: | 51 | python -m pip install --upgrade pip 52 | pip install hatch 53 | - name: Build and publish on PyPI 54 | env: 55 | HATCH_INDEX_USER: ${{ secrets.HATCH_INDEX_USER }} 56 | HATCH_INDEX_AUTH: ${{ secrets.HATCH_INDEX_AUTH }} 57 | run: | 58 | hatch build 59 | hatch publish 60 | - name: Create release 61 | uses: ncipollo/release-action@v1 62 | with: 63 | draft: true 64 | body: ${{ github.event.head_commit.message }} 65 | artifacts: dist/*.whl,dist/*.tar.gz 66 | token: ${{ secrets.GITHUB_TOKEN }} 67 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | junit/ 50 | junit.xml 51 | test.db 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # OS files 108 | .DS_Store 109 | 110 | # Terraform 111 | .terraform 112 | 113 | # SQLite databases 114 | *.db 115 | 116 | s3cfg 117 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '38 10 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [[tool.mypy.overrides]] 2 | module = "jwcrypto.*" 3 | ignore_missing_imports = true 4 | 5 | [[tool.mypy.overrides]] 6 | module = "yaspin.*" 7 | ignore_missing_imports = true 8 | 9 | [tool.ruff] 10 | target-version = "py39" 11 | 12 | [tool.ruff.lint] 13 | extend-select = ["I", "UP", "TRY"] 14 | ignore = ["E501"] 15 | per-file-ignores = {"tests/test_init.py" = ["F401"]} 16 | 17 | [tool.coverage.run] 18 | concurrency = ["greenlet", "thread"] 19 | 20 | [tool.pytest.ini_options] 21 | asyncio_mode = "strict" 22 | 23 | [tool.hatch] 24 | 25 | [tool.hatch.metadata] 26 | allow-direct-references = true 27 | 28 | [tool.hatch.version] 29 | source = "regex_commit" 30 | commit_extra_args = ["-e"] 31 | path = "fief_client/__init__.py" 32 | 33 | [tool.hatch.envs.default] 34 | installer = "uv" 35 | features = [ 36 | "fastapi", 37 | "flask", 38 | "cli", 39 | ] 40 | dependencies = [ 41 | "coverage[toml]", 42 | "greenlet", 43 | "mypy", 44 | "pytest", 45 | "pytest-cov", 46 | "pytest-asyncio", 47 | "pytest-mock", 48 | "respx", 49 | "ruff", 50 | "uvicorn[standard]", 51 | ] 52 | 53 | [tool.hatch.envs.docs] 54 | dependencies = [ 55 | "pdoc" 56 | ] 57 | 58 | [tool.hatch.envs.default.scripts] 59 | test = "pytest --cov fief_client/ --cov-report=term-missing" 60 | test-cov-xml = "pytest --cov fief_client/ --cov-report=xml" 61 | lint = [ 62 | "ruff format . ", 63 | "ruff check --fix .", 64 | "mypy fief_client/", 65 | ] 66 | lint-check = [ 67 | "ruff format --check .", 68 | "ruff check .", 69 | "mypy fief_client/", 70 | ] 71 | 72 | [tool.hatch.envs.docs.scripts] 73 | serve = "pdoc fief_client/" 74 | build = "pdoc fief_client/ -o docs/_build" 75 | 76 | [build-system] 77 | requires = ["hatchling", "hatch-regex-commit"] 78 | build-backend = "hatchling.build" 79 | 80 | [project] 81 | name = "fief-client" 82 | authors = [ 83 | { name = "François Voron", email = "contact@fief.dev" } 84 | ] 85 | description = "Fief Client for Python" 86 | readme = "README.md" 87 | license = "MIT" 88 | classifiers = [ 89 | "License :: OSI Approved :: MIT License", 90 | "Intended Audience :: Developers", 91 | "Programming Language :: Python :: 3.9", 92 | "Programming Language :: Python :: 3.10", 93 | "Programming Language :: Python :: 3.11", 94 | "Programming Language :: Python :: 3.12", 95 | "Programming Language :: Python :: 3 :: Only", 96 | ] 97 | dynamic = ["version"] 98 | requires-python = ">=3.9" 99 | dependencies = [ 100 | "httpx >=0.21.3,<0.28.0", 101 | "jwcrypto >=1.4,<2.0.0", 102 | ] 103 | 104 | [project.optional-dependencies] 105 | fastapi = [ 106 | "fastapi", 107 | "makefun >=1.14.0,<2.0.0", 108 | ] 109 | 110 | flask = [ 111 | "flask", 112 | ] 113 | 114 | cli = [ 115 | "yaspin", 116 | ] 117 | 118 | [project.urls] 119 | Documentation = "https://docs.fief.dev/integrate/python/" 120 | Source = "https://github.com/fief-dev/fief-python" 121 | -------------------------------------------------------------------------------- /tests/jwks.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "d": "LuGy1INhrWuUMNnpED2axB0ZfAVC3Vnlr_9GE3DQ5Wzthr_4rqKP47ousjCPt6oqZUm57iFa0a_-hg4p8U5xFTOgBBA44Fx7VKN_Pl2cLnlGwK--IkhytKWFnpMcoPQhJlJMcTI4jRew91XThOSiVscmzUk033ZjZNwrywmhh3Sok-UZHnj2dN-o_f8d0VsekwUVlfw0qdCQSrAZM3Ys8IukypriyL_qzj5Iu0RYnwB57bSzY9JdXRUN2NKRVTsVYSgFGE9QTFWxS-nWRrpMzop16B-wjzd6yHsIxhTqyq266xSMgwfGlOS9LJ8I16MUb04aYJgFimmR_56ChV81oQ", 5 | "dp": "4tYcM-scwgIOZ2fyWc_Xfk7bXR68e0ral_1EgxQ4HBTNE8dnFMpIywfLo8NjgbpnK-4jHj_hHftcuPBLYCyGym0KE2u_DXyXFxHqb-xjV4MJOkdsMVuB1OmXhOL17tzk9uhaKJ0Bc8mOtP72QVCpO3hvsBXKUSUKS4CmNcNwI6E", 6 | "dq": "0ZAtRKHDobx8W5ibSHDhkg4Ov-j0VKG6KWhnfoC-lvpGawzkgcl1ddd1J7jJ7N4pXsxWxMWBq6FDUnUujiqu51XGVjy382cnGyvDe26x5Cjb8aNBk-WLp6Oa_chVjsXW0iyIJBOIy-pHUkrrzPMTsz2aOej4SLFj3da-9FTtCz0", 7 | "e": "AQAB", 8 | "kid": "fief-client-tests-sig", 9 | "kty": "RSA", 10 | "n": "2G7TwA7TpxUp_TmGRz5OFDHfaoSPco-Zdgbj2E6PFITHJ0RxqA5mZeOfkJz-7A2ZIOUUjilG9isjq7BdeY0QJr-I-ZWe62T7CwPkn4Be2z0L7_WXcZV6Nolh0GfI9rVhWgMUVRfDZVnqMsKFo6wq6JVqeeeocU6_pNDor9BUjzsL5WyimNPuh3r6EXevZP5WJ4mjtPDWGOMEeJbE1kXBO5dlg8bi01h5wHzHY6tMpgfZjV3Jad0tX-3IImIzN5-spnz10NPn_tLeM567IMOfHJVn2l1mCqYP1sNT_AQbBm3YNE_nq9sWczU-n8_I6SOX1w8sncFO5ghfKxyhWCJLCw", 11 | "p": "_jWlsOK5zmffZvz1_eiKvkJfBzA68sta2w2AqrptnZQtSLjixrZgVGbrY3Q621wAumcGtUeCVkZwJ49klLtR-nvIP9U3O5X6V7sWEiumXVKU5T9Gf-27tjoMY_6TlZ660OuxWR-h8DgLM0KEqLBo1aHp54J2QndUeMx1EQ_s1vc", 12 | "q": "2fURFCBjd83XxZ2OrjtMbSdkBLH6S1q1yLOSrXwCQfkO_ADsqyjSkpenoDXky4oP57ZXos3IiHwNjUfkjNENYN5dLEZoE9rHsFnklw7tcPLqJBCjL2t3sdTfmQyzhFSTHes3fHMwGoiTP8fUg0i-lJOYE0lFT8LA8tPKE6NtA40", 13 | "qi": "PbfdEtmi8TwPSsvWPld5QkXMIfojhMDe2G6cFQpOrQ6eGXPIlbQGR2bQfchZ25x-5hWqfKhgF3R8lPQCEcpNj0dlyG8ATVLeEb-lLFpBWMWklxJvK0VAqSWrzYlMT9aPNNvjUU3q2wkUtmfmMMVUKrpw5h6TLz38ALfGLWeuzxc", 14 | "use": "sig" 15 | }, 16 | { 17 | "d": "PsIKI702o5w8SxfZl4-si0fp9RboWmnM0xs-rA9P7lqMyXc7hSLakQFNkUZXV6vruGMwG0zV3f9XQz2a5IBlXhQkkHfGG-81n6W-54mdbwmEfsR59idC91_HvswS3gVVMHy1HreZVarj5Bia8aGZ3aaK2OtOhYHMQEfzJa7W0YB6ttNa-9Vrf7BSwTpyZa_PXr03amCrQMOWLdVgRW1SgODjQ0NyC5bIG5OVA_oTM-0AvQomWhUzZB6KJYQ0NeH_PwXaRRKgwUva7ICxhyzUlAHJqNJ6nY2e25X8Gn6A5Q9t61pGVyy3BoKhHWgd8ahNnsvDZcyh6KOyrwtGJFR04Q", 18 | "dp": "hAdYZUpZv4OXoAExh_exYpwaKQ3FfW_RoRiQvCT8S5r1DezJDfrq4_slxiTzqGd-9RiVhWvTj3aBEhe9v5cTNHa_WNHzAPZQRYNfjfftPiHpLMZ2gikobQKKsegTigK1lH7zVV1u9r8v_9gcXIEZGBlDgJBUxo4dNhF3HwH7pPE", 19 | "dq": "RLsSIlLK-VoaODfSax0MKCiUi0ded527bNL7-B4Hy2DysgemFN_nzEFNpxNJwftuxbRlsyWS1lIVf1uSgBGzE0i8thkDC5ePRZKI4E3JW29yy5hg5XM6xx0PgqA6Q8RJLRTafpOEplT5X7UwBsE0xaJDsI--mGKWXUxxvIsfIdM", 20 | "e": "AQAB", 21 | "kid": "fief-client-tests-enc", 22 | "kty": "RSA", 23 | "n": "vvRZ3_kWUZeGqmoxRRrfyjxebf94bUa-rTgJgASEL4YLn8ahARYFKBaEjZP4CXO4mJiFAO0m1u0tNQf6COfV3CXTuOG1_3gMoetfnBLWih6uvieKIL851IIcb9DbwzJTSnX-Zog7NFWLxbhFoKlijo78y-Jf-Pe2hnlX42dNV61Zx2lApZjGi_3YF1xUdajByDkZox0oUSHfjFu97NlrZPwDwa2KxP82W1eybvIdzMQNo9IQ2kElK-f0NJF3dk-Fhm-35xcIzGzQnsV3OoVUU8grAYW9s_aOMl4YrBzz_8duPc5ArSqw7d7XSIgO2Qy3M1KuR-YraVPIgKso-ytn0w", 24 | "p": "8lOrsRHPKSBg15sfUT72-c8fxrN_odwpC2hzYEbWYrCAp7ikZrlO5FyrtBv5TJIqmu3FwVzm2gJtVq1tm-8-Vc3E9qrgLmkDRnOmrZj_csjBG3MDubVMZE7Go-2ZZCqVa5TjEs-dm4rmO7GYopXPaflxulrKEgt0gyJi13NEo9E", 25 | "q": "ybqf0FzJ97BWPiPqV5AYFxNrC9eeH7bXxqg-xYR53X_G_BgrV-a9MiAxQfILmBh_aT_0tprSbP76P-rtBX51qgiwus5by4VEPZw27TGU9GUxI5ysdAa-dYNzzbBf-_Keqsgai9OB3IAVJixR_xLyMcUESS99TS8SchuqTLXIrmM", 26 | "qi": "CE2_MvL-an_1gmG99f6y8TwPpblDPyxz6G1u4XHSXrTRoBgwsJsAbl5mxiEHRCaTFzEOV80Uak--Pn9ALIMYHDV0L7JPRu0J_7MY5gZQgmLe5o2jwW_rDu1uo8IKp3GO5HQmVX9ZYKKbIijqQICOmwd7oz7Nd8ykS-0Ol7gMkbA", 27 | "use": "enc" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import uuid 3 | from collections.abc import Generator 4 | from datetime import datetime, timezone 5 | from os import path 6 | from typing import Callable, Protocol 7 | 8 | import pytest 9 | import pytest_asyncio 10 | import respx 11 | from httpx import Response 12 | from jwcrypto import jwk, jwt 13 | 14 | from fief_client import FiefACR 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def keys() -> jwk.JWKSet: 19 | with open(path.join(path.dirname(__file__), "jwks.json")) as jwks_file: 20 | return jwk.JWKSet.from_json(jwks_file.read()) 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def signature_key(keys: jwk.JWKSet) -> jwk.JWK: 25 | return keys.get_key("fief-client-tests-sig") 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def encryption_key(keys: jwk.JWKSet) -> jwk.JWK: 30 | return keys.get_key("fief-client-tests-enc") 31 | 32 | 33 | @pytest.fixture(scope="session") 34 | def user_id() -> str: 35 | return str(uuid.uuid4()) 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def generate_token(signature_key: jwk.JWK, encryption_key: jwk.JWK, user_id: str): 40 | def _generate_token(encrypt: bool, **kwargs) -> str: 41 | iat = int(datetime.now(timezone.utc).timestamp()) 42 | exp = iat + 3600 43 | 44 | claims = { 45 | "sub": user_id, 46 | "email": "anne@bretagne.duchy", 47 | "iss": "https://bretagne.fief.dev", 48 | "aud": ["CLIENT_ID"], 49 | "exp": exp, 50 | "iat": iat, 51 | "azp": "CLIENT_ID", 52 | **kwargs, 53 | } 54 | 55 | signed_token = jwt.JWT(header={"alg": "RS256"}, claims=claims) 56 | signed_token.make_signed_token(signature_key) 57 | 58 | if encrypt: 59 | encrypted_token = jwt.JWT( 60 | header={"alg": "RSA-OAEP-256", "enc": "A256CBC-HS512"}, 61 | claims=signed_token.serialize(), 62 | ) 63 | encrypted_token.make_encrypted_token(encryption_key) 64 | return encrypted_token.serialize() 65 | 66 | return signed_token.serialize() 67 | 68 | return _generate_token 69 | 70 | 71 | @pytest.fixture(scope="session") 72 | def generate_access_token(generate_token: Callable[..., str]): 73 | def _generate_access_token( 74 | encrypt: bool, 75 | *, 76 | scope: str = "", 77 | permissions: list[str] = [], 78 | acr: FiefACR = FiefACR.LEVEL_ZERO, 79 | **kwargs, 80 | ) -> str: 81 | return generate_token( 82 | encrypt=encrypt, scope=scope, permissions=permissions, acr=acr, **kwargs 83 | ) 84 | 85 | return _generate_access_token 86 | 87 | 88 | @pytest.fixture(scope="session") 89 | def access_token(generate_access_token: Callable[..., str]) -> str: 90 | return generate_access_token(encrypt=False) 91 | 92 | 93 | @pytest.fixture(scope="session") 94 | def signed_id_token(generate_token: Callable[..., str]) -> str: 95 | return generate_token(encrypt=False) 96 | 97 | 98 | @pytest.fixture(scope="session") 99 | def encrypted_id_token(generate_token: Callable[..., str]) -> str: 100 | return generate_token(encrypt=True) 101 | 102 | 103 | class GetAPIRequestsMock(Protocol): 104 | def __call__( 105 | self, *, hostname: str = "https://bretagne.fief.dev", path_prefix: str = "" 106 | ) -> contextlib.AbstractContextManager[respx.MockRouter]: ... 107 | 108 | 109 | @pytest_asyncio.fixture(scope="module") 110 | def get_api_requests_mock(signature_key: jwk.JWK) -> GetAPIRequestsMock: 111 | @contextlib.contextmanager 112 | def _get_api_requests_mock( 113 | *, hostname: str = "https://bretagne.fief.dev", path_prefix: str = "" 114 | ) -> Generator[respx.MockRouter, None, None]: 115 | with respx.mock(assert_all_mocked=True, assert_all_called=False) as respx_mock: 116 | openid_configuration_route = respx_mock.get( 117 | f"{path_prefix}/.well-known/openid-configuration" 118 | ) 119 | openid_configuration_route.return_value = Response( 120 | 200, 121 | json={ 122 | "issuer": f"{hostname}{path_prefix}", 123 | "authorization_endpoint": f"{hostname}{path_prefix}/authorize", 124 | "token_endpoint": f"{hostname}{path_prefix}/token", 125 | "userinfo_endpoint": f"{hostname}{path_prefix}/userinfo", 126 | "jwks_uri": f"{hostname}{path_prefix}/.well-known/jwks.json", 127 | }, 128 | ) 129 | 130 | jwks_route = respx_mock.get(f"{path_prefix}/.well-known/jwks.json") 131 | jwks_route.return_value = Response( 132 | 200, 133 | json={"keys": [signature_key.export(private_key=False, as_dict=True)]}, 134 | ) 135 | 136 | print("YIELD") 137 | yield respx_mock 138 | print("RESET") 139 | 140 | return _get_api_requests_mock 141 | 142 | 143 | @pytest_asyncio.fixture(scope="module", autouse=True) 144 | def mock_api_requests( 145 | get_api_requests_mock: GetAPIRequestsMock, 146 | ) -> Generator[respx.MockRouter, None, None]: 147 | with get_api_requests_mock() as respx_mock: 148 | yield respx_mock 149 | -------------------------------------------------------------------------------- /tests/test_integrations_cli.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import json 3 | import tempfile 4 | from collections.abc import Generator 5 | from typing import BinaryIO 6 | from unittest.mock import MagicMock 7 | 8 | import httpx 9 | import pytest 10 | import respx 11 | from pytest_mock import MockerFixture 12 | 13 | from fief_client.client import FiefAccessTokenExpired 14 | from fief_client.integrations.cli import ( 15 | FiefAuth, 16 | FiefAuthAuthorizationCodeMissingError, 17 | FiefAuthNotAuthenticatedError, 18 | FiefAuthRefreshTokenMissingError, 19 | ) 20 | 21 | 22 | @pytest.fixture() 23 | def fief_client() -> MagicMock: 24 | return MagicMock() 25 | 26 | 27 | @pytest.fixture() 28 | def credentials_file() -> Generator[BinaryIO, None, None]: 29 | with tempfile.NamedTemporaryFile() as file: 30 | yield file # type: ignore 31 | 32 | 33 | @pytest.fixture(autouse=True) 34 | def webbrowser_open_mock(mocker: MockerFixture) -> MagicMock: 35 | return mocker.patch("webbrowser.open") 36 | 37 | 38 | class TestAuthorize: 39 | def test_valid_code( 40 | self, 41 | fief_client: MagicMock, 42 | credentials_file: BinaryIO, 43 | webbrowser_open_mock: MagicMock, 44 | ): 45 | fief_client.auth_callback.return_value = ( 46 | {"access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN"}, 47 | {"email": "anne@bretagne.duchy"}, 48 | ) 49 | fief_auth = FiefAuth(fief_client, credentials_file.name) 50 | 51 | with respx.mock(assert_all_mocked=False) as respx_mock: 52 | respx_mock.get("http://localhost:51562/callback").pass_through() 53 | with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: 54 | authorize_task = executor.submit(fief_auth.authorize) 55 | with httpx.Client() as client: 56 | response = client.get( 57 | "http://localhost:51562/callback", 58 | params={"code": "AUTHORIZATION_CODE"}, 59 | ) 60 | assert response.status_code == 200 61 | assert ( 62 | response.headers["content-type"] == "text/html; charset=utf-8" 63 | ) 64 | authorize_task.result() 65 | 66 | webbrowser_open_mock.assert_called_once() 67 | 68 | credentials_file_content = credentials_file.read() 69 | assert credentials_file_content != "" 70 | assert json.loads(credentials_file_content) == { 71 | "userinfo": {"email": "anne@bretagne.duchy"}, 72 | "tokens": { 73 | "access_token": "ACCESS_TOKEN", 74 | "refresh_token": "REFRESH_TOKEN", 75 | }, 76 | } 77 | 78 | def test_missing_code(self, fief_client: MagicMock, credentials_file: BinaryIO): 79 | fief_auth = FiefAuth(fief_client, credentials_file.name) 80 | 81 | with respx.mock(assert_all_mocked=False) as respx_mock: 82 | respx_mock.get("http://localhost:51562/callback").pass_through() 83 | with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: 84 | authorize_task = executor.submit(fief_auth.authorize) 85 | with httpx.Client() as client: 86 | response = client.get("http://localhost:51562/callback", params={}) 87 | assert response.status_code == 400 88 | assert ( 89 | response.headers["content-type"] == "text/html; charset=utf-8" 90 | ) 91 | with pytest.raises(FiefAuthAuthorizationCodeMissingError): 92 | authorize_task.result() 93 | 94 | 95 | class TestAccessTokenInfo: 96 | def test_no_saved_credentials( 97 | self, fief_client: MagicMock, credentials_file: BinaryIO 98 | ): 99 | fief_auth = FiefAuth(fief_client, credentials_file.name) 100 | with pytest.raises(FiefAuthNotAuthenticatedError): 101 | fief_auth.access_token_info() 102 | 103 | def test_valid_token(self, fief_client: MagicMock, credentials_file: BinaryIO): 104 | credentials_file.write( 105 | json.dumps( 106 | { 107 | "userinfo": {"email": "anne@bretagne.duchy"}, 108 | "tokens": { 109 | "access_token": "ACCESS_TOKEN", 110 | "refresh_token": "REFRESH_TOKEN", 111 | }, 112 | } 113 | ).encode("utf-8") 114 | ) 115 | credentials_file.seek(0) 116 | 117 | access_token_info = { 118 | "id": "USER_ID", 119 | "scope": ["openid", "offline_access"], 120 | "permissions": [], 121 | "access_token": "ACCESS_TOKEN", 122 | } 123 | fief_client.validate_access_token.return_value = access_token_info 124 | 125 | fief_auth = FiefAuth(fief_client, credentials_file.name) 126 | assert fief_auth.access_token_info() == access_token_info 127 | 128 | def test_expired_token_refresh( 129 | self, fief_client: MagicMock, credentials_file: BinaryIO 130 | ): 131 | credentials_file.write( 132 | json.dumps( 133 | { 134 | "userinfo": {"email": "anne@bretagne.duchy"}, 135 | "tokens": { 136 | "access_token": "EXPIRED_ACCESS_TOKEN", 137 | "refresh_token": "REFRESH_TOKEN", 138 | }, 139 | } 140 | ).encode("utf-8") 141 | ) 142 | credentials_file.seek(0) 143 | 144 | access_token_info = { 145 | "id": "USER_ID", 146 | "scope": ["openid", "offline_access"], 147 | "permissions": [], 148 | "access_token": "ACCESS_TOKEN", 149 | } 150 | 151 | def validate_access_token_mock(access_token: str): 152 | if access_token == "EXPIRED_ACCESS_TOKEN": 153 | raise FiefAccessTokenExpired() 154 | return access_token_info 155 | 156 | fief_client.validate_access_token.side_effect = validate_access_token_mock 157 | fief_client.auth_refresh_token.return_value = ( 158 | {"access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN"}, 159 | {"email": "anne@bretagne.duchy"}, 160 | ) 161 | 162 | fief_auth = FiefAuth(fief_client, credentials_file.name) 163 | assert fief_auth.access_token_info() == access_token_info 164 | fief_client.auth_refresh_token.assert_called_once_with("REFRESH_TOKEN") 165 | 166 | def test_expired_token_missing_refresh_token( 167 | self, fief_client: MagicMock, credentials_file: BinaryIO 168 | ): 169 | credentials_file.write( 170 | json.dumps( 171 | { 172 | "userinfo": {"email": "anne@bretagne.duchy"}, 173 | "tokens": {"access_token": "EXPIRED_ACCESS_TOKEN"}, 174 | } 175 | ).encode("utf-8") 176 | ) 177 | credentials_file.seek(0) 178 | 179 | fief_client.validate_access_token.side_effect = FiefAccessTokenExpired() 180 | 181 | fief_auth = FiefAuth(fief_client, credentials_file.name) 182 | with pytest.raises(FiefAuthRefreshTokenMissingError): 183 | fief_auth.access_token_info() 184 | 185 | def test_expired_token_no_refresh( 186 | self, fief_client: MagicMock, credentials_file: BinaryIO 187 | ): 188 | credentials_file.write( 189 | json.dumps( 190 | { 191 | "userinfo": {"email": "anne@bretagne.duchy"}, 192 | "tokens": {"access_token": "EXPIRED_ACCESS_TOKEN"}, 193 | } 194 | ).encode("utf-8") 195 | ) 196 | credentials_file.seek(0) 197 | 198 | fief_client.validate_access_token.side_effect = FiefAccessTokenExpired() 199 | 200 | fief_auth = FiefAuth(fief_client, credentials_file.name) 201 | with pytest.raises(FiefAccessTokenExpired): 202 | fief_auth.access_token_info(refresh=False) 203 | 204 | 205 | class TestCurrentUser: 206 | def test_no_saved_credentials( 207 | self, fief_client: MagicMock, credentials_file: BinaryIO 208 | ): 209 | fief_auth = FiefAuth(fief_client, credentials_file.name) 210 | with pytest.raises(FiefAuthNotAuthenticatedError): 211 | fief_auth.current_user() 212 | 213 | def test_no_refresh(self, fief_client: MagicMock, credentials_file: BinaryIO): 214 | credentials_file.write( 215 | json.dumps( 216 | { 217 | "userinfo": {"email": "anne@bretagne.duchy"}, 218 | "tokens": { 219 | "access_token": "ACCESS_TOKEN", 220 | "refresh_token": "REFRESH_TOKEN", 221 | }, 222 | } 223 | ).encode("utf-8") 224 | ) 225 | credentials_file.seek(0) 226 | 227 | fief_auth = FiefAuth(fief_client, credentials_file.name) 228 | assert fief_auth.current_user() == {"email": "anne@bretagne.duchy"} 229 | 230 | def test_refresh(self, fief_client: MagicMock, credentials_file: BinaryIO): 231 | credentials_file.write( 232 | json.dumps( 233 | { 234 | "userinfo": {"email": "anne@bretagne.duchy"}, 235 | "tokens": { 236 | "access_token": "ACCESS_TOKEN", 237 | "refresh_token": "REFRESH_TOKEN", 238 | }, 239 | } 240 | ).encode("utf-8") 241 | ) 242 | credentials_file.seek(0) 243 | 244 | fief_client.userinfo.return_value = {"email": "anne+updated@bretagne.duchy"} 245 | 246 | fief_auth = FiefAuth(fief_client, credentials_file.name) 247 | assert fief_auth.current_user(refresh=True) == { 248 | "email": "anne+updated@bretagne.duchy" 249 | } 250 | -------------------------------------------------------------------------------- /fief_client/integrations/flask.py: -------------------------------------------------------------------------------- 1 | """Flask integration.""" 2 | 3 | import uuid 4 | from functools import wraps 5 | from typing import Callable, Optional 6 | 7 | from flask import g, request 8 | 9 | from fief_client import ( 10 | Fief, 11 | FiefAccessTokenACRTooLow, 12 | FiefAccessTokenExpired, 13 | FiefAccessTokenInfo, 14 | FiefAccessTokenInvalid, 15 | FiefAccessTokenMissingScope, 16 | FiefACR, 17 | FiefUserInfo, 18 | ) 19 | from fief_client.client import FiefAccessTokenMissingPermission 20 | 21 | 22 | class FiefAuthError(Exception): 23 | """ 24 | Base error for FiefAuth integration. 25 | """ 26 | 27 | 28 | class FiefAuthUnauthorized(FiefAuthError): 29 | """ 30 | Request unauthorized error. 31 | 32 | This error is raised when using the `authenticated` or `current_user` decorator 33 | but the request is not authenticated. 34 | 35 | You should implement an `errorhandler` to define the behavior of your server when 36 | this happens. 37 | 38 | **Example:** 39 | 40 | ```py 41 | @app.errorhandler(FiefAuthUnauthorized) 42 | def fief_unauthorized_error(e): 43 | return "", 401 44 | ``` 45 | """ 46 | 47 | 48 | class FiefAuthForbidden(FiefAuthError): 49 | """ 50 | Request forbidden error. 51 | 52 | This error is raised when using the `authenticated` or `current_user` decorator 53 | but the access token doesn't match the list of scopes, permissions or minimum ACR level. 54 | 55 | You should implement an `errorhandler` to define the behavior of your server when 56 | this happens. 57 | 58 | **Example:** 59 | 60 | ```py 61 | @app.errorhandler(FiefAuthForbidden) 62 | def fief_forbidden_error(e): 63 | return "", 403 64 | ``` 65 | """ 66 | 67 | 68 | TokenGetter = Callable[[], Optional[str]] 69 | """Type of a function that can be used to retrieve a token.""" 70 | 71 | UserInfoCacheGetter = Callable[[uuid.UUID], Optional[FiefUserInfo]] 72 | """ 73 | Type of a function that can be used to retrieve user information from a cache. 74 | 75 | Read more: https://docs.fief.dev/integrate/python/flask/#web-application-example 76 | """ 77 | 78 | UserInfoCacheSetter = Callable[[uuid.UUID, FiefUserInfo], None] 79 | """ 80 | Type of a function that can be used to store user information in a cache. 81 | 82 | Read more: https://docs.fief.dev/integrate/python/flask/#web-application-example 83 | """ 84 | 85 | 86 | def get_authorization_scheme_token(*, scheme: str = "bearer") -> TokenGetter: 87 | """ 88 | Return a `TokenGetter` function to retrieve a token from the `Authorization` header of an HTTP request. 89 | 90 | :param scheme: Scheme of the token. Defaults to `bearer`. 91 | """ 92 | 93 | def _get_authorization_scheme_token(): 94 | authorization = request.headers.get("Authorization") 95 | if authorization is None: 96 | return None 97 | parts = authorization.split() 98 | if len(parts) != 2 or parts[0].lower() != scheme.lower(): 99 | return None 100 | return parts[1] 101 | 102 | return _get_authorization_scheme_token 103 | 104 | 105 | def get_cookie(cookie_name: str) -> TokenGetter: 106 | """ 107 | Return a `TokenGetter` function to retrieve a token from a `Cookie` of an HTTP request. 108 | 109 | :param cookie_name: Name of the cookie. 110 | """ 111 | 112 | def _get_cookie(): 113 | return request.cookies.get(cookie_name) 114 | 115 | return _get_cookie 116 | 117 | 118 | class FiefAuth: 119 | """ 120 | Helper class to integrate Fief authentication with Flask. 121 | 122 | **Example:** 123 | 124 | ```py 125 | from fief_client import Fief 126 | from fief_client.integrations.flask import ( 127 | FiefAuth, 128 | get_authorization_scheme_token, 129 | ) 130 | from flask import Flask, g 131 | 132 | fief = Fief( 133 | "https://example.fief.dev", 134 | "YOUR_CLIENT_ID", 135 | "YOUR_CLIENT_SECRET", 136 | ) 137 | 138 | auth = FiefAuth(fief, get_authorization_scheme_token()) 139 | 140 | app = Flask(__name__) 141 | ``` 142 | """ 143 | 144 | def __init__( 145 | self, 146 | client: Fief, 147 | token_getter: TokenGetter, 148 | *, 149 | get_userinfo_cache: Optional[UserInfoCacheGetter] = None, 150 | set_userinfo_cache: Optional[UserInfoCacheSetter] = None, 151 | ) -> None: 152 | """ 153 | :param client: Instance of a `fief_client.Fief` client. 154 | :param token_getter: Function to retrieve a token. 155 | It should follow the `TokenGetter` type. 156 | :param get_userinfo_cache: Optional function to retrieve user information from a cache. 157 | Otherwise, the Fief API will always be reached when requesting user information. 158 | It should follow the `UserInfoCacheGetter` type. 159 | :param set_userinfo_cache: Optional function to store user information in a cache. 160 | It should follow the `UserInfoCacheSetter` type. 161 | """ 162 | self.client = client 163 | self.token_getter = token_getter 164 | self.get_userinfo_cache = get_userinfo_cache 165 | self.set_userinfo_cache = set_userinfo_cache 166 | 167 | def authenticated( 168 | self, 169 | *, 170 | optional: bool = False, 171 | scope: Optional[list[str]] = None, 172 | acr: Optional[FiefACR] = None, 173 | permissions: Optional[list[str]] = None, 174 | ): 175 | """ 176 | Decorator to check if a request is authenticated. 177 | 178 | If the request is authenticated, the `g` object will have an `access_token_info` property, 179 | of type `fief_client.FiefAccessTokenInfo`. 180 | 181 | :param optional: If `False` and the request is not authenticated, 182 | a `FiefAuthUnauthorized` error will be raised. 183 | :param scope: Optional list of scopes required. 184 | If the access token lacks one of the required scope, a `FiefAuthForbidden` error will be raised. 185 | :param acr: Optional minimum ACR level required. 186 | If the access token doesn't meet the minimum level, a `FiefAuthForbidden` error will be raised. 187 | Read more: https://docs.fief.dev/going-further/acr/ 188 | :param permissions: Optional list of permissions required. 189 | If the access token lacks one of the required permission, a `FiefAuthForbidden` error will be raised. 190 | 191 | **Example** 192 | 193 | ```py 194 | @app.get("/authenticated") 195 | @auth.authenticated() 196 | def get_authenticated(): 197 | return g.access_token_info 198 | ``` 199 | """ 200 | 201 | def _authenticated(f): 202 | @wraps(f) 203 | def decorated_function(*args, **kwargs): 204 | token = self.token_getter() 205 | if token is None: 206 | if optional: 207 | g.access_token_info = None 208 | return f(*args, **kwargs) 209 | raise FiefAuthUnauthorized() 210 | 211 | try: 212 | info = self.client.validate_access_token( 213 | token, 214 | required_scope=scope, 215 | required_acr=acr, 216 | required_permissions=permissions, 217 | ) 218 | except (FiefAccessTokenInvalid, FiefAccessTokenExpired) as e: 219 | if optional: 220 | g.access_token_info = None 221 | return f(*args, **kwargs) 222 | raise FiefAuthUnauthorized() from e 223 | except ( 224 | FiefAccessTokenMissingScope, 225 | FiefAccessTokenACRTooLow, 226 | FiefAccessTokenMissingPermission, 227 | ) as e: 228 | raise FiefAuthForbidden() from e 229 | 230 | g.access_token_info = info 231 | 232 | return f(*args, **kwargs) 233 | 234 | return decorated_function 235 | 236 | return _authenticated 237 | 238 | def current_user( 239 | self, 240 | *, 241 | optional: bool = False, 242 | scope: Optional[list[str]] = None, 243 | acr: Optional[FiefACR] = None, 244 | permissions: Optional[list[str]] = None, 245 | refresh: bool = False, 246 | ): 247 | """ 248 | Decorator to check if a user is authenticated. 249 | 250 | If the request is authenticated, the `g` object will have a `user` property, 251 | of type `fief_client.FiefUserInfo`. 252 | 253 | :param optional: If `False` and the request is not authenticated, 254 | a `FiefAuthUnauthorized` error will be raised. 255 | :param scope: Optional list of scopes required. 256 | If the access token lacks one of the required scope, a `FiefAuthForbidden` error will be raised. 257 | :param acr: Optional minimum ACR level required. 258 | If the access token doesn't meet the minimum level, a `FiefAuthForbidden` error will be raised. 259 | Read more: https://docs.fief.dev/going-further/acr/ 260 | :param permissions: Optional list of permissions required. 261 | If the access token lacks one of the required permission, a `FiefAuthForbidden` error will be raised. 262 | :param refresh: If `True`, the user information will be refreshed from the Fief API. 263 | Otherwise, the cache will be used. 264 | 265 | **Example** 266 | 267 | ```py 268 | @app.get("/current-user") 269 | @auth.current_user() 270 | def get_current_user(): 271 | user = g.user 272 | return f"

You are authenticated. Your user email is {user['email']}

" 273 | ``` 274 | """ 275 | 276 | def _current_user(f): 277 | @wraps(f) 278 | @self.authenticated( 279 | optional=optional, scope=scope, acr=acr, permissions=permissions 280 | ) 281 | def decorated_function(*args, **kwargs): 282 | access_token_info: Optional[FiefAccessTokenInfo] = g.access_token_info 283 | 284 | if access_token_info is None and optional: 285 | g.user = None 286 | return f(*args, **kwargs) 287 | 288 | assert access_token_info is not None 289 | 290 | userinfo = None 291 | if self.get_userinfo_cache is not None: 292 | userinfo = self.get_userinfo_cache(access_token_info["id"]) 293 | 294 | if userinfo is None or refresh: 295 | userinfo = self.client.userinfo(access_token_info["access_token"]) 296 | 297 | if self.set_userinfo_cache is not None: 298 | self.set_userinfo_cache(access_token_info["id"], userinfo) 299 | 300 | g.user = userinfo 301 | 302 | return f(*args, **kwargs) 303 | 304 | return decorated_function 305 | 306 | return _current_user 307 | 308 | 309 | __all__ = [ 310 | "FiefAuth", 311 | "FiefAuthError", 312 | "FiefAuthUnauthorized", 313 | "FiefAuthForbidden", 314 | "TokenGetter", 315 | "UserInfoCacheGetter", 316 | "UserInfoCacheSetter", 317 | "get_authorization_scheme_token", 318 | "get_cookie", 319 | ] 320 | -------------------------------------------------------------------------------- /fief_client/integrations/fastapi.py: -------------------------------------------------------------------------------- 1 | """FastAPI integration.""" 2 | 3 | import uuid 4 | from collections.abc import AsyncGenerator, Coroutine, Generator 5 | from inspect import Parameter, Signature, isawaitable 6 | from typing import ( 7 | Callable, 8 | Optional, 9 | Protocol, 10 | TypeVar, 11 | Union, 12 | cast, 13 | ) 14 | 15 | from fastapi import Depends, HTTPException, Request, Response, status 16 | from fastapi.security.base import SecurityBase 17 | from fastapi.security.http import HTTPAuthorizationCredentials 18 | from makefun import with_signature 19 | 20 | from fief_client import ( 21 | Fief, 22 | FiefAccessTokenACRTooLow, 23 | FiefAccessTokenExpired, 24 | FiefAccessTokenInfo, 25 | FiefAccessTokenInvalid, 26 | FiefAccessTokenMissingPermission, 27 | FiefAccessTokenMissingScope, 28 | FiefACR, 29 | FiefAsync, 30 | FiefUserInfo, 31 | ) 32 | 33 | FiefClientClass = Union[Fief, FiefAsync] 34 | 35 | TokenType = Union[str, HTTPAuthorizationCredentials] 36 | 37 | RETURN_TYPE = TypeVar("RETURN_TYPE") 38 | 39 | DependencyCallable = Callable[ 40 | ..., 41 | Union[ 42 | RETURN_TYPE, 43 | Coroutine[None, None, RETURN_TYPE], 44 | AsyncGenerator[RETURN_TYPE, None], 45 | Generator[RETURN_TYPE, None, None], 46 | ], 47 | ] 48 | 49 | 50 | class UserInfoCacheProtocol(Protocol): 51 | """ 52 | Protocol that should follow a class to implement a cache mechanism for user information. 53 | 54 | Read more: https://docs.fief.dev/integrate/python/fastapi/#caching-user-information 55 | """ 56 | 57 | async def get(self, user_id: uuid.UUID) -> Optional[FiefUserInfo]: 58 | """ 59 | Retrieve user information from cache, if available. 60 | 61 | :param user_id: The ID of the user to retrieve information for. 62 | """ 63 | ... # pragma: no cover 64 | 65 | async def set(self, user_id: uuid.UUID, userinfo: FiefUserInfo) -> None: 66 | """ 67 | Store user information in cache. 68 | 69 | :param user_id: The ID of the user to cache information for. 70 | :param userinfo: The user information to cache. 71 | """ 72 | ... # pragma: no cover 73 | 74 | 75 | class FiefAuth: 76 | """ 77 | Helper class to integrate Fief authentication with FastAPI. 78 | 79 | **Example:** 80 | 81 | ```py 82 | from fastapi.security import OAuth2AuthorizationCodeBearer 83 | from fief_client import FiefAccessTokenInfo, FiefAsync 84 | from fief_client.integrations.fastapi import FiefAuth 85 | 86 | fief = FiefAsync( 87 | "https://example.fief.dev", 88 | "YOUR_CLIENT_ID", 89 | "YOUR_CLIENT_SECRET", 90 | ) 91 | 92 | scheme = OAuth2AuthorizationCodeBearer( 93 | "https://example.fief.dev/authorize", 94 | "https://example.fief.dev/api/token", 95 | scopes={"openid": "openid", "offline_access": "offline_access"}, 96 | ) 97 | 98 | auth = FiefAuth(fief, scheme) 99 | ``` 100 | """ 101 | 102 | def __init__( 103 | self, 104 | client: FiefClientClass, 105 | scheme: SecurityBase, 106 | *, 107 | get_userinfo_cache: Optional[DependencyCallable[UserInfoCacheProtocol]] = None, 108 | ) -> None: 109 | """ 110 | :param client: Instance of a Fief client. 111 | Can be either `fief_client.Fief` or `fief_client.FiefAsync`. 112 | :param scheme: FastAPI security scheme. 113 | It'll be used to retrieve the access token in the request. 114 | :param get_userinfo_cache: Optional dependency returning an instance of a class 115 | following the `UserInfoCacheProtocol`. 116 | It'll be used to cache user information on your server. 117 | Otherwise, the Fief API will always be reached when requesting user information. 118 | """ 119 | self.client = client 120 | self.scheme = scheme 121 | self.get_userinfo_cache = get_userinfo_cache 122 | 123 | def authenticated( 124 | self, 125 | optional: bool = False, 126 | scope: Optional[list[str]] = None, 127 | acr: Optional[FiefACR] = None, 128 | permissions: Optional[list[str]] = None, 129 | ): 130 | """ 131 | Return a FastAPI dependency to check if a request is authenticated. 132 | 133 | If the request is authenticated, the dependency will return a `fief_client.FiefAccessTokenInfo`. 134 | 135 | :param optional: If `False` and the request is not authenticated, 136 | an unauthorized response will be raised. 137 | :param scope: Optional list of scopes required. 138 | If the access token lacks one of the required scope, a forbidden response will be raised. 139 | :param acr: Optional minimum ACR level required. 140 | If the access token doesn't meet the minimum level, a forbidden response will be raised. 141 | Read more: https://docs.fief.dev/going-further/acr/ 142 | :param permissions: Optional list of permissions required. 143 | If the access token lacks one of the required permission, a forbidden response will be raised. 144 | 145 | **Example** 146 | 147 | ```py 148 | @app.get("/authenticated") 149 | async def get_authenticated( 150 | access_token_info: FiefAccessTokenInfo = Depends(auth.authenticated()), 151 | ): 152 | return access_token_info 153 | ``` 154 | """ 155 | signature = self._get_authenticated_call_signature(self.scheme) 156 | 157 | @with_signature(signature) 158 | async def _authenticated( 159 | request: Request, response: Response, token: Optional[TokenType] 160 | ) -> Optional[FiefAccessTokenInfo]: 161 | if token is None: 162 | if optional: 163 | return None 164 | return await self.get_unauthorized_response(request, response) 165 | 166 | if isinstance(token, HTTPAuthorizationCredentials): 167 | token = token.credentials 168 | 169 | try: 170 | result = self.client.validate_access_token( 171 | token, 172 | required_scope=scope, 173 | required_acr=acr, 174 | required_permissions=permissions, 175 | ) 176 | if isawaitable(result): 177 | info = await result 178 | else: 179 | info = result 180 | except (FiefAccessTokenInvalid, FiefAccessTokenExpired): 181 | if optional: 182 | return None 183 | return await self.get_unauthorized_response(request, response) 184 | except ( 185 | FiefAccessTokenMissingScope, 186 | FiefAccessTokenACRTooLow, 187 | FiefAccessTokenMissingPermission, 188 | ): 189 | return await self.get_forbidden_response(request, response) 190 | 191 | return info 192 | 193 | return _authenticated 194 | 195 | def current_user( 196 | self, 197 | optional: bool = False, 198 | scope: Optional[list[str]] = None, 199 | acr: Optional[FiefACR] = None, 200 | permissions: Optional[list[str]] = None, 201 | refresh: bool = False, 202 | ): 203 | """ 204 | Return a FastAPI dependency to check if a user is authenticated. 205 | 206 | If the request is authenticated, the dependency will return a `fief_client.FiefUserInfo`. 207 | 208 | If provided, the cache mechanism will be used to retrieve this information without calling the Fief API. 209 | 210 | :param optional: If `False` and the request is not authenticated, 211 | an unauthorized response will be raised. 212 | :param scope: Optional list of scopes required. 213 | If the access token lacks one of the required scope, a forbidden response will be raised. 214 | :param acr: Optional minimum ACR level required. 215 | If the access token doesn't meet the minimum level, a forbidden response will be raised. 216 | Read more: https://docs.fief.dev/going-further/acr/ 217 | :param permissions: Optional list of permissions required. 218 | If the access token lacks one of the required permission, a forbidden response will be raised. 219 | :param refresh: If `True`, the user information will be refreshed from the Fief API. 220 | Otherwise, the cache will be used. 221 | 222 | **Example** 223 | 224 | ```py 225 | @app.get("/current-user", name="current_user") 226 | async def get_current_user( 227 | user: FiefUserInfo = Depends(auth.current_user()), 228 | ): 229 | return {"email": user["email"]} 230 | ``` 231 | """ 232 | signature = self._get_current_user_call_signature( 233 | self.authenticated(optional, scope, acr, permissions) 234 | ) 235 | 236 | @with_signature(signature) 237 | async def _current_user( 238 | access_token_info: Optional[FiefAccessTokenInfo], *args, **kwargs 239 | ) -> Optional[FiefUserInfo]: 240 | userinfo_cache: Optional[UserInfoCacheProtocol] = kwargs.get( 241 | "userinfo_cache" 242 | ) 243 | 244 | if access_token_info is None and optional: 245 | return None 246 | assert access_token_info is not None 247 | 248 | userinfo = None 249 | if userinfo_cache is not None: 250 | userinfo = await userinfo_cache.get(access_token_info["id"]) 251 | 252 | if userinfo is None or refresh: 253 | result = self.client.userinfo(access_token_info["access_token"]) 254 | if isawaitable(result): 255 | userinfo = cast(FiefUserInfo, await result) 256 | else: 257 | userinfo = cast(FiefUserInfo, result) 258 | 259 | if userinfo_cache is not None: 260 | await userinfo_cache.set(access_token_info["id"], userinfo) 261 | 262 | return userinfo 263 | 264 | return _current_user 265 | 266 | async def get_unauthorized_response(self, request: Request, response: Response): 267 | """ 268 | Raise an `fastapi.HTTPException` with the status code 401. 269 | 270 | This method is called when using the `authenticated` or `current_user` dependency 271 | but the request is not authenticated. 272 | 273 | You can override this method to customize the behavior in this case. 274 | """ 275 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) 276 | 277 | async def get_forbidden_response(self, request: Request, response: Response): 278 | """ 279 | Raise an `fastapi.HTTPException` with the status code 403. 280 | 281 | This method is called when using the `authenticated` or `current_user` dependency 282 | but the access token doesn't match the list of scopes or permissions. 283 | 284 | You can override this method to customize the behavior in this case. 285 | """ 286 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 287 | 288 | def _get_authenticated_call_signature(self, scheme: SecurityBase) -> Signature: 289 | """ 290 | Generate a dynamic signature for the authenticated dependency. 291 | Here comes some blood magic 🧙‍♂️ 292 | Thank to "makefun", we are able to generate callable 293 | with a dynamic security scheme dependency at runtime. 294 | This way, it's detected by the OpenAPI generator. 295 | """ 296 | parameters: list[Parameter] = [ 297 | Parameter( 298 | name="request", 299 | kind=Parameter.POSITIONAL_OR_KEYWORD, 300 | annotation=Request, 301 | ), 302 | Parameter( 303 | name="response", 304 | kind=Parameter.POSITIONAL_OR_KEYWORD, 305 | annotation=Response, 306 | ), 307 | Parameter( 308 | name="token", 309 | kind=Parameter.POSITIONAL_OR_KEYWORD, 310 | default=Depends(cast(Callable, scheme)), 311 | ), 312 | ] 313 | 314 | return Signature(parameters) 315 | 316 | def _get_current_user_call_signature(self, authenticated: Callable) -> Signature: 317 | """ 318 | Generate a dynamic signature for the current_user dependency. 319 | Here comes some blood magic 🧙‍♂️ 320 | Thank to "makefun", we are able to generate callable 321 | with a dynamic security scheme dependency at runtime. 322 | This way, it's detected by the OpenAPI generator. 323 | """ 324 | parameters: list[Parameter] = [ 325 | Parameter( 326 | name="access_token_info", 327 | kind=Parameter.POSITIONAL_OR_KEYWORD, 328 | default=Depends(authenticated), 329 | annotation=FiefAccessTokenInfo, 330 | ), 331 | ] 332 | 333 | if self.get_userinfo_cache is not None: 334 | parameters.append( 335 | Parameter( 336 | name="userinfo_cache", 337 | kind=Parameter.POSITIONAL_OR_KEYWORD, 338 | default=Depends(self.get_userinfo_cache), 339 | annotation=UserInfoCacheProtocol, 340 | ), 341 | ) 342 | 343 | return Signature(parameters) 344 | 345 | 346 | __all__ = ["FiefAuth", "FiefClientClass", "UserInfoCacheProtocol"] 347 | -------------------------------------------------------------------------------- /fief_client/integrations/cli.py: -------------------------------------------------------------------------------- 1 | """CLI integration.""" 2 | 3 | import functools 4 | import http 5 | import http.server 6 | import json 7 | import pathlib 8 | import queue 9 | import typing 10 | import urllib.parse 11 | import webbrowser 12 | 13 | from yaspin import yaspin 14 | from yaspin.spinners import Spinners 15 | 16 | from fief_client import ( 17 | Fief, 18 | FiefAccessTokenExpired, 19 | FiefAccessTokenInfo, 20 | FiefTokenResponse, 21 | FiefUserInfo, 22 | ) 23 | from fief_client.pkce import get_code_challenge, get_code_verifier 24 | 25 | 26 | class FiefAuthError(Exception): 27 | """ 28 | Base error for FiefAuth integration. 29 | """ 30 | 31 | 32 | class FiefAuthNotAuthenticatedError(FiefAuthError): 33 | """ 34 | The user is not authenticated. 35 | """ 36 | 37 | pass 38 | 39 | 40 | class FiefAuthAuthorizationCodeMissingError(FiefAuthError): 41 | """ 42 | The authorization code was not found in the redirection URL. 43 | """ 44 | 45 | pass 46 | 47 | 48 | class FiefAuthRefreshTokenMissingError(FiefAuthError): 49 | """ 50 | The refresh token is missing in the saved credentials. 51 | """ 52 | 53 | pass 54 | 55 | 56 | class CallbackHTTPServer(http.server.ThreadingHTTPServer): 57 | pass 58 | 59 | 60 | class CallbackHTTPRequestHandler(http.server.BaseHTTPRequestHandler): 61 | def __init__( 62 | self, 63 | *args, 64 | queue: "queue.Queue[str]", 65 | render_success_page, 66 | render_error_page, 67 | **kwargs, 68 | ) -> None: 69 | self.queue = queue 70 | self.render_success_page = render_success_page 71 | self.render_error_page = render_error_page 72 | super().__init__(*args, **kwargs) 73 | 74 | def log_message(self, format: str, *args: typing.Any) -> None: 75 | pass 76 | 77 | def do_GET(self): 78 | parsed_url = urllib.parse.urlparse(self.path) 79 | query_params = urllib.parse.parse_qs(parsed_url.query) 80 | 81 | try: 82 | code = query_params["code"][0] 83 | except (KeyError, IndexError): 84 | output = self.render_error_page(query_params).encode("utf-8") 85 | self.send_response(http.HTTPStatus.BAD_REQUEST) 86 | self.send_header("Content-type", "text/html; charset=utf-8") 87 | self.send_header("Content-Length", str(len(output))) 88 | self.end_headers() 89 | self.wfile.write(output) 90 | else: 91 | self.queue.put(code) 92 | 93 | output = self.render_success_page().encode("utf-8") 94 | self.send_response(http.HTTPStatus.OK) 95 | self.send_header("Content-type", "text/html; charset=utf-8") 96 | self.send_header("Content-Length", str(len(output))) 97 | self.end_headers() 98 | self.wfile.write(output) 99 | 100 | self.server.shutdown() 101 | 102 | 103 | class FiefAuth: 104 | """ 105 | Helper class to integrate Fief authentication in a CLI tool. 106 | 107 | **Example:** 108 | 109 | ```py 110 | from fief_client import Fief 111 | from fief_client.integrations.cli import FiefAuth 112 | 113 | fief = Fief( 114 | "https://example.fief.dev", 115 | "YOUR_CLIENT_ID", 116 | ) 117 | auth = FiefAuth(fief, "./credentials.json") 118 | ``` 119 | """ 120 | 121 | _userinfo: typing.Optional[FiefUserInfo] = None 122 | _tokens: typing.Optional[FiefTokenResponse] = None 123 | 124 | def __init__(self, client: Fief, credentials_path: str) -> None: 125 | """ 126 | :param client: Instance of a Fief client. 127 | :param credentials_path: Path where the credentials will be stored on the user machine. 128 | We recommend you to use a library like [appdir](https://github.com/ActiveState/appdirs) 129 | to determine a reasonable path depending on the user's operating system. 130 | """ 131 | self.client = client 132 | self.credentials_path = pathlib.Path(credentials_path) 133 | self._load_stored_credentials() 134 | 135 | def access_token_info(self, refresh: bool = True) -> FiefAccessTokenInfo: 136 | """ 137 | Return credentials information saved on disk. 138 | 139 | Optionally, it can automatically get a fresh `access_token` if 140 | the saved one is expired. 141 | 142 | :param refresh: Whether the client should automatically refresh the token. 143 | Defaults to `True`. 144 | 145 | :raises: `FiefAuthNotAuthenticatedError` if the user is not authenticated. 146 | :raises: `fief_client.FiefAccessTokenExpired` if the access token is expired and automatic refresh is disabled. 147 | """ 148 | if self._tokens is None: 149 | raise FiefAuthNotAuthenticatedError() 150 | 151 | access_token = self._tokens["access_token"] 152 | try: 153 | return self.client.validate_access_token(access_token) 154 | except FiefAccessTokenExpired: 155 | if refresh: 156 | self._refresh_access_token() 157 | return self.access_token_info() 158 | raise 159 | 160 | def current_user(self, refresh: bool = False) -> FiefUserInfo: 161 | """ 162 | Return user information saved on disk. 163 | 164 | Optionally, it can automatically refresh it from the server if there 165 | is a valid access token. 166 | 167 | :param refresh: Whether the client should refresh the user information. 168 | Defaults to `False`. 169 | 170 | :raises: `FiefAuthNotAuthenticatedError` if the user is not authenticated. 171 | """ 172 | if self._tokens is None or self._userinfo is None: 173 | raise FiefAuthNotAuthenticatedError() 174 | if refresh: 175 | access_token_info = self.access_token_info() 176 | userinfo = self.client.userinfo(access_token_info["access_token"]) 177 | self._save_credentials(self._tokens, userinfo) 178 | return self._userinfo 179 | 180 | def authorize( 181 | self, 182 | server_address: tuple[str, int] = ("localhost", 51562), 183 | redirect_path: str = "/callback", 184 | *, 185 | scope: typing.Optional[list[str]] = None, 186 | lang: typing.Optional[str] = None, 187 | extras_params: typing.Optional[typing.Mapping[str, str]] = None, 188 | ) -> tuple[FiefTokenResponse, FiefUserInfo]: 189 | """ 190 | Perform a user authentication with the Fief server. 191 | 192 | It'll automatically open the user's default browser and redirect them 193 | to the Fief authorization page. 194 | 195 | Under the hood, the client opens a temporary web server. 196 | 197 | After a successful authentication, Fief will redirect to this web server 198 | so the client can catch the authorization code and generate a valid access token. 199 | 200 | Finally, it'll automatically save the credentials on disk. 201 | 202 | :param server_address: The address of the temporary web server the client should open. 203 | It's a tuple composed of the IP and the port. Defaults to `("localhost", 51562)`. 204 | :param redirect_path: Redirect URI where Fief will redirect after a successful authentication. 205 | Defaults to `/callback`. 206 | :param scope: Optional list of scopes to ask for. 207 | The client will **always** ask at least for `openid` and `offline_access`. 208 | :param lang: Optional parameter to set the user locale on the authentication pages. 209 | Should be a valid [RFC 3066](https://www.rfc-editor.org/rfc/rfc3066) language identifier, like `fr` or `pt-PT`. 210 | :param extras_params: Optional dictionary containing [specific parameters](https://docs.fief.dev/going-further/authorize-url/). 211 | 212 | **Example:** 213 | 214 | ```py 215 | tokens, userinfo = auth.authorize() 216 | ``` 217 | """ 218 | redirect_uri = f"http://{server_address[0]}:{server_address[1]}{redirect_path}" 219 | 220 | scope_set: set[str] = set(scope) if scope else set() 221 | scope_set.add("openid") 222 | scope_set.add("offline_access") 223 | 224 | code_verifier = get_code_verifier() 225 | code_challenge = get_code_challenge(code_verifier) 226 | 227 | authorization_url = self.client.auth_url( 228 | redirect_uri, 229 | scope=list(scope_set), 230 | code_challenge=code_challenge, 231 | code_challenge_method="S256", 232 | lang=lang, 233 | extras_params=extras_params, 234 | ) 235 | webbrowser.open(authorization_url) 236 | 237 | with yaspin( 238 | text="Please complete authentication in your browser.", 239 | spinner=Spinners.dots, 240 | ) as spinner: 241 | code_queue: queue.Queue[str] = queue.Queue() 242 | server = CallbackHTTPServer( 243 | server_address, 244 | functools.partial( 245 | CallbackHTTPRequestHandler, 246 | queue=code_queue, 247 | render_success_page=self.render_success_page, 248 | render_error_page=self.render_error_page, 249 | ), 250 | ) 251 | 252 | server.serve_forever() 253 | 254 | try: 255 | code = code_queue.get(block=False) 256 | except queue.Empty as e: 257 | raise FiefAuthAuthorizationCodeMissingError() from e 258 | 259 | spinner.text = "Getting a token..." 260 | 261 | tokens, userinfo = self.client.auth_callback( 262 | code, redirect_uri, code_verifier=code_verifier 263 | ) 264 | self._save_credentials(tokens, userinfo) 265 | 266 | spinner.ok("Successfully authenticated") 267 | 268 | return tokens, userinfo 269 | 270 | def render_success_page(self) -> str: 271 | """ 272 | Generate the HTML page that'll be shown to the user after a successful redirection. 273 | 274 | By default, it just tells the user that it can go back to the CLI. 275 | 276 | You can override this method if you want to customize this page. 277 | """ 278 | return f""" 279 | 280 | 281 | 282 | 283 | 284 |
285 |
286 |
287 |
288 |
289 |
290 |

Done! You can go back to your terminal!

291 |
292 |
293 |
294 |
295 |
296 | 303 | 304 | 305 | """ 306 | 307 | def render_error_page(self, query_params: dict[str, typing.Any]) -> str: 308 | """ 309 | Generate the HTML page that'll be shown to the user when something goes wrong during redirection. 310 | 311 | You can override this method if you want to customize this page. 312 | """ 313 | return f""" 314 | 315 | 316 | 317 | 318 | 319 |
320 |
321 |
322 |
323 |
324 |
325 |

Something went wrong! You're not authenticated.

326 |

Error detail: {json.dumps(query_params)}

327 |
328 |
329 |
330 |
331 |
332 | 333 | 334 | """ 335 | 336 | def _refresh_access_token(self): 337 | refresh_token = self._tokens.get("refresh_token") 338 | if refresh_token is None: 339 | raise FiefAuthRefreshTokenMissingError() 340 | tokens, userinfo = self.client.auth_refresh_token(refresh_token) 341 | self._save_credentials(tokens, userinfo) 342 | 343 | def _load_stored_credentials(self): 344 | if self.credentials_path.exists(): 345 | with open(self.credentials_path) as file: 346 | try: 347 | data = json.loads(file.read()) 348 | self._userinfo = data["userinfo"] 349 | self._tokens = data["tokens"] 350 | except json.decoder.JSONDecodeError: 351 | pass 352 | 353 | def _save_credentials(self, tokens: FiefTokenResponse, userinfo: FiefUserInfo): 354 | self._tokens = tokens 355 | self._userinfo = userinfo 356 | with open(self.credentials_path, "w") as file: 357 | data = {"userinfo": userinfo, "tokens": tokens} 358 | file.write(json.dumps(data)) 359 | 360 | 361 | __all__ = [ 362 | "FiefAuth", 363 | "FiefAuthError", 364 | "FiefAuthNotAuthenticatedError", 365 | "FiefAuthAuthorizationCodeMissingError", 366 | "FiefAuthRefreshTokenMissingError", 367 | ] 368 | -------------------------------------------------------------------------------- /tests/test_integrations_flask.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from collections.abc import Generator 3 | from typing import Optional 4 | 5 | import pytest 6 | import respx 7 | from flask import Flask, g, session 8 | from flask.testing import FlaskClient 9 | from httpx import Response 10 | 11 | from fief_client.client import Fief, FiefACR, FiefUserInfo 12 | from fief_client.integrations.flask import ( 13 | FiefAuth, 14 | FiefAuthForbidden, 15 | FiefAuthUnauthorized, 16 | get_authorization_scheme_token, 17 | get_cookie, 18 | ) 19 | 20 | 21 | @pytest.fixture(scope="module") 22 | def fief_client() -> Fief: 23 | return Fief("https://bretagne.fief.dev", "CLIENT_ID", "CLIENT_SECRET") 24 | 25 | 26 | @pytest.fixture(scope="module") 27 | def flask_app(fief_client: Fief) -> Generator[Flask, None, None]: 28 | def get_userinfo_cache(id: uuid.UUID) -> Optional[FiefUserInfo]: 29 | return session.get(f"userinfo-{str(id)}") 30 | 31 | def set_userinfo_cache(id: uuid.UUID, userinfo: FiefUserInfo) -> None: 32 | session[f"userinfo-{str(id)}"] = userinfo 33 | 34 | auth = FiefAuth( 35 | fief_client, 36 | get_authorization_scheme_token(), 37 | get_userinfo_cache=get_userinfo_cache, 38 | set_userinfo_cache=set_userinfo_cache, 39 | ) 40 | app = Flask(__name__) 41 | app.secret_key = "SECRET_KEY" 42 | app.config.update({"TESTING": True}) 43 | 44 | @app.errorhandler(FiefAuthUnauthorized) 45 | def fief_unauthorized_error(e): 46 | return "", 401 47 | 48 | @app.errorhandler(FiefAuthForbidden) 49 | def fief_forbidden_error(e): 50 | return "", 403 51 | 52 | @app.get("/authenticated") 53 | @auth.authenticated() 54 | def get_authenticated(): 55 | return g.access_token_info 56 | 57 | @app.get("/authenticated-optional") 58 | @auth.authenticated(optional=True) 59 | def get_authenticated_optional(): 60 | return g.access_token_info or {} 61 | 62 | @app.get("/authenticated-scope") 63 | @auth.authenticated(scope=["required_scope"]) 64 | def get_authenticated_scope(): 65 | return g.access_token_info 66 | 67 | @app.get("/authenticated-acr") 68 | @auth.authenticated(acr=FiefACR.LEVEL_ONE) 69 | def get_authenticated_acr(): 70 | return g.access_token_info 71 | 72 | @app.get("/authenticated-permission") 73 | @auth.authenticated(permissions=["castles:create"]) 74 | def get_authenticated_permission(): 75 | return g.access_token_info 76 | 77 | @app.get("/current-user") 78 | @auth.current_user() 79 | def get_current_user(): 80 | return g.user 81 | 82 | @app.get("/current-user-optional") 83 | @auth.current_user(optional=True) 84 | def get_current_user_optional(): 85 | return g.user or {} 86 | 87 | @app.get("/current-user-refresh") 88 | @auth.current_user(refresh=True) 89 | def get_current_user_refresh(): 90 | return g.user 91 | 92 | @app.get("/current-user-scope") 93 | @auth.current_user(scope=["required_scope"]) 94 | def get_current_user_scope(): 95 | return g.user 96 | 97 | @app.get("/current-user-acr") 98 | @auth.current_user(acr=FiefACR.LEVEL_ONE) 99 | def get_current_user_acr(): 100 | return g.user 101 | 102 | @app.get("/current-user-permission") 103 | @auth.current_user(permissions=["castles:create"]) 104 | def get_current_user_permission(): 105 | return g.user 106 | 107 | yield app 108 | 109 | 110 | @pytest.fixture 111 | def test_client(flask_app: Flask) -> FlaskClient: 112 | return flask_app.test_client() 113 | 114 | 115 | class TestAuthenticated: 116 | def test_missing_token(self, test_client: FlaskClient): 117 | response = test_client.get("/authenticated") 118 | 119 | assert response.status_code == 401 120 | 121 | def test_invalid_authorization_header(self, test_client: FlaskClient): 122 | response = test_client.get("/authenticated", headers={"Authorization": "TOKEN"}) 123 | 124 | assert response.status_code == 401 125 | 126 | def test_invalid_token(self, test_client: FlaskClient): 127 | response = test_client.get( 128 | "/authenticated", headers={"Authorization": "Bearer INVALID_TOKEN"} 129 | ) 130 | 131 | assert response.status_code == 401 132 | 133 | def test_expired_token(self, test_client: FlaskClient, generate_access_token): 134 | access_token = generate_access_token(encrypt=False, exp=0) 135 | 136 | response = test_client.get( 137 | "/authenticated", headers={"Authorization": f"Bearer {access_token}"} 138 | ) 139 | 140 | assert response.status_code == 401 141 | 142 | def test_valid_token( 143 | self, test_client: FlaskClient, generate_access_token, user_id: str 144 | ): 145 | access_token = generate_access_token(encrypt=False, scope="openid") 146 | 147 | response = test_client.get( 148 | "/authenticated", headers={"Authorization": f"Bearer {access_token}"} 149 | ) 150 | 151 | assert response.status_code == 200 152 | 153 | json = response.json 154 | assert json == { 155 | "id": user_id, 156 | "scope": ["openid"], 157 | "acr": FiefACR.LEVEL_ZERO, 158 | "permissions": [], 159 | "access_token": access_token, 160 | } 161 | 162 | def test_optional( 163 | self, test_client: FlaskClient, generate_access_token, user_id: str 164 | ): 165 | response = test_client.get("/authenticated-optional") 166 | assert response.status_code == 200 167 | assert response.json == {} 168 | 169 | expired_access_token = generate_access_token( 170 | encrypt=False, scope="openid", exp=0 171 | ) 172 | response = test_client.get( 173 | "/authenticated-optional", 174 | headers={"Authorization": f"Bearer {expired_access_token}"}, 175 | ) 176 | assert response.status_code == 200 177 | assert response.json == {} 178 | 179 | access_token = generate_access_token(encrypt=False, scope="openid") 180 | response = test_client.get( 181 | "/authenticated-optional", 182 | headers={"Authorization": f"Bearer {access_token}"}, 183 | ) 184 | assert response.status_code == 200 185 | assert response.json == { 186 | "id": user_id, 187 | "scope": ["openid"], 188 | "acr": FiefACR.LEVEL_ZERO, 189 | "permissions": [], 190 | "access_token": access_token, 191 | } 192 | 193 | def test_missing_scope(self, test_client: FlaskClient, generate_access_token): 194 | access_token = generate_access_token(encrypt=False, scope="openid") 195 | 196 | response = test_client.get( 197 | "/authenticated-scope", headers={"Authorization": f"Bearer {access_token}"} 198 | ) 199 | 200 | assert response.status_code == 403 201 | 202 | def test_valid_scope( 203 | self, test_client: FlaskClient, generate_access_token, user_id: str 204 | ): 205 | access_token = generate_access_token( 206 | encrypt=False, scope="openid required_scope" 207 | ) 208 | 209 | response = test_client.get( 210 | "/authenticated-scope", headers={"Authorization": f"Bearer {access_token}"} 211 | ) 212 | 213 | assert response.status_code == 200 214 | 215 | json = response.json 216 | assert json == { 217 | "id": user_id, 218 | "scope": ["openid", "required_scope"], 219 | "acr": FiefACR.LEVEL_ZERO, 220 | "permissions": [], 221 | "access_token": access_token, 222 | } 223 | 224 | def test_invalid_acr(self, test_client: FlaskClient, generate_access_token): 225 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ZERO) 226 | 227 | response = test_client.get( 228 | "/authenticated-acr", headers={"Authorization": f"Bearer {access_token}"} 229 | ) 230 | 231 | assert response.status_code == 403 232 | 233 | def test_valid_acr( 234 | self, test_client: FlaskClient, generate_access_token, user_id: str 235 | ): 236 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ONE) 237 | 238 | response = test_client.get( 239 | "/authenticated-acr", headers={"Authorization": f"Bearer {access_token}"} 240 | ) 241 | 242 | assert response.status_code == 200 243 | 244 | json = response.json 245 | assert json == { 246 | "id": user_id, 247 | "scope": [], 248 | "acr": FiefACR.LEVEL_ONE, 249 | "permissions": [], 250 | "access_token": access_token, 251 | } 252 | 253 | def test_missing_permission(self, test_client: FlaskClient, generate_access_token): 254 | access_token = generate_access_token( 255 | encrypt=False, permissions=["castles:read"] 256 | ) 257 | 258 | response = test_client.get( 259 | "/authenticated-permission", 260 | headers={"Authorization": f"Bearer {access_token}"}, 261 | ) 262 | 263 | assert response.status_code == 403 264 | 265 | def test_valid_permission( 266 | self, test_client: FlaskClient, generate_access_token, user_id: str 267 | ): 268 | access_token = generate_access_token( 269 | encrypt=False, permissions=["castles:read", "castles:create"] 270 | ) 271 | 272 | response = test_client.get( 273 | "/authenticated-permission", 274 | headers={"Authorization": f"Bearer {access_token}"}, 275 | ) 276 | 277 | assert response.status_code == 200 278 | 279 | json = response.json 280 | assert json == { 281 | "id": user_id, 282 | "scope": [], 283 | "acr": FiefACR.LEVEL_ZERO, 284 | "permissions": ["castles:read", "castles:create"], 285 | "access_token": access_token, 286 | } 287 | 288 | 289 | class TestCurrentUser: 290 | def test_missing_token(self, test_client: FlaskClient): 291 | response = test_client.get("/current-user") 292 | 293 | assert response.status_code == 401 294 | 295 | def test_invalid_authorization_header(self, test_client: FlaskClient): 296 | response = test_client.get("/current-user", headers={"Authorization": "TOKEN"}) 297 | 298 | assert response.status_code == 401 299 | 300 | def test_expired_token(self, test_client: FlaskClient, generate_access_token): 301 | access_token = generate_access_token(encrypt=False, exp=0) 302 | 303 | response = test_client.get( 304 | "/current-user", headers={"Authorization": f"Bearer {access_token}"} 305 | ) 306 | 307 | assert response.status_code == 401 308 | 309 | def test_valid_token( 310 | self, 311 | test_client: FlaskClient, 312 | generate_access_token, 313 | mock_api_requests: respx.MockRouter, 314 | user_id: str, 315 | ): 316 | mock_api_requests.get("/userinfo").reset() 317 | mock_api_requests.get("/userinfo").return_value = Response( 318 | 200, json={"sub": user_id} 319 | ) 320 | 321 | access_token = generate_access_token(encrypt=False, scope="openid") 322 | 323 | response = test_client.get( 324 | "/current-user", headers={"Authorization": f"Bearer {access_token}"} 325 | ) 326 | 327 | assert response.status_code == 200 328 | 329 | json = response.json 330 | assert json == {"sub": user_id} 331 | 332 | # Check cache is working 333 | response_2 = test_client.get( 334 | "/current-user", headers={"Authorization": f"Bearer {access_token}"} 335 | ) 336 | 337 | assert response_2.status_code == 200 338 | 339 | json = response_2.json 340 | assert json == {"sub": user_id} 341 | 342 | assert mock_api_requests.get("/userinfo").call_count == 1 343 | 344 | def test_optional( 345 | self, 346 | test_client: FlaskClient, 347 | generate_access_token, 348 | mock_api_requests: respx.MockRouter, 349 | user_id: str, 350 | ): 351 | mock_api_requests.get("/userinfo").reset() 352 | mock_api_requests.get("/userinfo").return_value = Response( 353 | 200, json={"sub": user_id} 354 | ) 355 | 356 | response = test_client.get("/current-user-optional") 357 | assert response.status_code == 200 358 | assert response.json == {} 359 | 360 | expired_access_token = generate_access_token( 361 | encrypt=False, scope="openid", exp=0 362 | ) 363 | response = test_client.get( 364 | "/current-user-optional", 365 | headers={"Authorization": f"Bearer {expired_access_token}"}, 366 | ) 367 | assert response.status_code == 200 368 | assert response.json == {} 369 | 370 | access_token = generate_access_token(encrypt=False, scope="openid") 371 | response = test_client.get( 372 | "/current-user-optional", 373 | headers={"Authorization": f"Bearer {access_token}"}, 374 | ) 375 | assert response.status_code == 200 376 | assert response.json == {"sub": user_id} 377 | 378 | def test_missing_scope(self, test_client: FlaskClient, generate_access_token): 379 | access_token = generate_access_token(encrypt=False, scope="openid") 380 | 381 | response = test_client.get( 382 | "/current-user-scope", headers={"Authorization": f"Bearer {access_token}"} 383 | ) 384 | 385 | assert response.status_code == 403 386 | 387 | def test_valid_scope( 388 | self, 389 | test_client: FlaskClient, 390 | generate_access_token, 391 | mock_api_requests: respx.MockRouter, 392 | user_id: str, 393 | ): 394 | mock_api_requests.get("/userinfo").return_value = Response( 395 | 200, json={"sub": user_id} 396 | ) 397 | 398 | access_token = generate_access_token( 399 | encrypt=False, scope="openid required_scope" 400 | ) 401 | 402 | response = test_client.get( 403 | "/current-user-scope", headers={"Authorization": f"Bearer {access_token}"} 404 | ) 405 | 406 | assert response.status_code == 200 407 | 408 | json = response.json 409 | assert json == {"sub": user_id} 410 | 411 | def test_missing_acr(self, test_client: FlaskClient, generate_access_token): 412 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ZERO) 413 | 414 | response = test_client.get( 415 | "/current-user-acr", headers={"Authorization": f"Bearer {access_token}"} 416 | ) 417 | 418 | assert response.status_code == 403 419 | 420 | def test_valid_acr( 421 | self, 422 | test_client: FlaskClient, 423 | generate_access_token, 424 | mock_api_requests: respx.MockRouter, 425 | user_id: str, 426 | ): 427 | mock_api_requests.get("/userinfo").return_value = Response( 428 | 200, json={"sub": user_id} 429 | ) 430 | 431 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ONE) 432 | 433 | response = test_client.get( 434 | "/current-user-acr", headers={"Authorization": f"Bearer {access_token}"} 435 | ) 436 | 437 | assert response.status_code == 200 438 | 439 | json = response.json 440 | assert json == {"sub": user_id} 441 | 442 | def test_missing_permission(self, test_client: FlaskClient, generate_access_token): 443 | access_token = generate_access_token( 444 | encrypt=False, permissions=["castles:read"] 445 | ) 446 | 447 | response = test_client.get( 448 | "/current-user-permission", 449 | headers={"Authorization": f"Bearer {access_token}"}, 450 | ) 451 | 452 | assert response.status_code == 403 453 | 454 | def test_valid_permission( 455 | self, 456 | test_client: FlaskClient, 457 | generate_access_token, 458 | mock_api_requests: respx.MockRouter, 459 | user_id: str, 460 | ): 461 | mock_api_requests.get("/userinfo").return_value = Response( 462 | 200, json={"sub": user_id} 463 | ) 464 | 465 | access_token = generate_access_token( 466 | encrypt=False, permissions=["castles:read", "castles:create"] 467 | ) 468 | 469 | response = test_client.get( 470 | "/current-user-permission", 471 | headers={"Authorization": f"Bearer {access_token}"}, 472 | ) 473 | 474 | assert response.status_code == 200 475 | 476 | json = response.json 477 | assert json == {"sub": user_id} 478 | 479 | def test_valid_refresh( 480 | self, 481 | test_client: FlaskClient, 482 | generate_access_token, 483 | mock_api_requests: respx.MockRouter, 484 | user_id: str, 485 | ): 486 | mock_api_requests.get("/userinfo").reset() 487 | mock_api_requests.get("/userinfo").return_value = Response( 488 | 200, json={"sub": user_id} 489 | ) 490 | 491 | access_token = generate_access_token(encrypt=False, scope="openid") 492 | 493 | response = test_client.get( 494 | "/current-user-refresh", headers={"Authorization": f"Bearer {access_token}"} 495 | ) 496 | 497 | assert response.status_code == 200 498 | 499 | json = response.json 500 | assert json == {"sub": user_id} 501 | 502 | # Check cache is not used with refresh 503 | response_2 = test_client.get( 504 | "/current-user-refresh", headers={"Authorization": f"Bearer {access_token}"} 505 | ) 506 | 507 | assert response_2.status_code == 200 508 | 509 | json = response_2.json 510 | assert json == {"sub": user_id} 511 | 512 | assert mock_api_requests.get("/userinfo").call_count == 2 513 | 514 | 515 | def test_get_cookie(): 516 | cookie_getter = get_cookie("COOKIE_NAME") 517 | app = Flask(__name__) 518 | with app.test_request_context(): 519 | result = cookie_getter() 520 | assert result is None 521 | 522 | with app.test_request_context(headers={"Cookie": "COOKIE_NAME=VALUE"}): 523 | result = cookie_getter() 524 | assert result == "VALUE" 525 | -------------------------------------------------------------------------------- /tests/test_integrations_fastapi.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from collections.abc import AsyncGenerator 3 | from typing import Optional 4 | 5 | import httpx 6 | import pytest 7 | import pytest_asyncio 8 | import respx 9 | from fastapi import Depends, FastAPI, status 10 | from fastapi.security.base import SecurityBase 11 | from fastapi.security.http import HTTPBearer 12 | from fastapi.security.oauth2 import OAuth2PasswordBearer 13 | from httpx import Response 14 | 15 | from fief_client.client import ( 16 | Fief, 17 | FiefAccessTokenInfo, 18 | FiefACR, 19 | FiefAsync, 20 | FiefUserInfo, 21 | ) 22 | from fief_client.integrations.fastapi import FiefAuth, FiefClientClass 23 | 24 | 25 | @pytest.fixture(scope="module", params=[Fief, FiefAsync]) 26 | def fief_client(request) -> FiefClientClass: 27 | fief_class = request.param 28 | return fief_class("https://bretagne.fief.dev", "CLIENT_ID", "CLIENT_SECRET") 29 | 30 | 31 | schemes: list[SecurityBase] = [ 32 | HTTPBearer(auto_error=False), 33 | OAuth2PasswordBearer("/token", auto_error=False), 34 | ] 35 | 36 | 37 | @pytest.fixture(scope="module", params=schemes) 38 | def scheme(request) -> SecurityBase: 39 | return request.param 40 | 41 | 42 | @pytest.fixture(scope="module") 43 | def fastapi_app(fief_client: FiefClientClass, scheme: SecurityBase) -> FastAPI: 44 | class MemoryUserinfoCache: 45 | def __init__(self) -> None: 46 | self.storage: dict[uuid.UUID, FiefUserInfo] = {} 47 | 48 | async def get(self, user_id: uuid.UUID) -> Optional[FiefUserInfo]: 49 | return self.storage.get(user_id) 50 | 51 | async def set(self, user_id: uuid.UUID, userinfo: FiefUserInfo) -> None: 52 | self.storage[user_id] = userinfo 53 | 54 | memory_userinfo_cache = MemoryUserinfoCache() 55 | 56 | async def get_memory_userinfo_cache() -> MemoryUserinfoCache: 57 | return memory_userinfo_cache 58 | 59 | auth = FiefAuth(fief_client, scheme, get_userinfo_cache=get_memory_userinfo_cache) 60 | app = FastAPI() 61 | 62 | @app.get("/authenticated") 63 | async def get_authenticated( 64 | access_token_info: FiefAccessTokenInfo = Depends(auth.authenticated()), 65 | ): 66 | return access_token_info 67 | 68 | @app.get("/authenticated-optional") 69 | async def get_authenticated_optional( 70 | access_token_info: Optional[FiefAccessTokenInfo] = Depends( 71 | auth.authenticated(optional=True) 72 | ), 73 | ): 74 | return access_token_info 75 | 76 | @app.get("/authenticated-scope") 77 | async def get_authenticated_scope( 78 | access_token_info: FiefAccessTokenInfo = Depends( 79 | auth.authenticated(scope=["required_scope"]) 80 | ), 81 | ): 82 | return access_token_info 83 | 84 | @app.get("/authenticated-acr") 85 | async def get_authenticated_acr( 86 | access_token_info: FiefAccessTokenInfo = Depends( 87 | auth.authenticated(acr=FiefACR.LEVEL_ONE) 88 | ), 89 | ): 90 | return access_token_info 91 | 92 | @app.get("/authenticated-permission") 93 | async def get_authenticated_permission( 94 | access_token_info: FiefAccessTokenInfo = Depends( 95 | auth.authenticated(permissions=["castles:create"]) 96 | ), 97 | ): 98 | return access_token_info 99 | 100 | @app.get("/current-user") 101 | async def get_current_user( 102 | current_user: FiefAccessTokenInfo = Depends(auth.current_user()), 103 | ): 104 | return current_user 105 | 106 | @app.get("/current-user-optional") 107 | async def get_current_user_optional( 108 | current_user: Optional[FiefUserInfo] = Depends( 109 | auth.current_user(optional=True) 110 | ), 111 | ): 112 | return current_user 113 | 114 | @app.get("/current-user-refresh") 115 | async def get_current_user_refresh( 116 | current_user: FiefUserInfo = Depends(auth.current_user(refresh=True)), 117 | ): 118 | return current_user 119 | 120 | @app.get("/current-user-scope") 121 | async def get_current_user_scope( 122 | current_user: FiefUserInfo = Depends( 123 | auth.current_user(scope=["required_scope"]) 124 | ), 125 | ): 126 | return current_user 127 | 128 | @app.get("/current-user-acr") 129 | async def get_current_user_acr( 130 | current_user: FiefUserInfo = Depends(auth.current_user(acr=FiefACR.LEVEL_ONE)), 131 | ): 132 | return current_user 133 | 134 | @app.get("/current-user-permission") 135 | async def get_current_user_permission( 136 | current_user: FiefUserInfo = Depends( 137 | auth.current_user(permissions=["castles:create"]) 138 | ), 139 | ): 140 | return current_user 141 | 142 | return app 143 | 144 | 145 | @pytest_asyncio.fixture 146 | async def test_client(fastapi_app: FastAPI) -> AsyncGenerator[httpx.AsyncClient, None]: 147 | async with httpx.AsyncClient( 148 | transport=httpx.ASGITransport(fastapi_app), base_url="http://api.bretagne.duchy" 149 | ) as test_client: 150 | yield test_client 151 | 152 | 153 | @pytest.mark.asyncio 154 | async def test_openapi(test_client: httpx.AsyncClient, scheme: SecurityBase): 155 | response = await test_client.get("/openapi.json") 156 | 157 | assert response.status_code == status.HTTP_200_OK 158 | 159 | json = response.json() 160 | assert scheme.scheme_name in json["components"]["securitySchemes"] 161 | 162 | 163 | @pytest.mark.asyncio 164 | class TestAuthenticated: 165 | async def test_missing_token(self, test_client: httpx.AsyncClient): 166 | response = await test_client.get("/authenticated") 167 | 168 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 169 | 170 | async def test_invalid_token(self, test_client: httpx.AsyncClient): 171 | response = await test_client.get( 172 | "/authenticated", headers={"Authorization": "Bearer INVALID_TOKEN"} 173 | ) 174 | 175 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 176 | 177 | async def test_expired_token( 178 | self, test_client: httpx.AsyncClient, generate_access_token 179 | ): 180 | access_token = generate_access_token(encrypt=False, exp=0) 181 | 182 | response = await test_client.get( 183 | "/authenticated", headers={"Authorization": f"Bearer {access_token}"} 184 | ) 185 | 186 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 187 | 188 | async def test_valid_token( 189 | self, test_client: httpx.AsyncClient, generate_access_token, user_id: str 190 | ): 191 | access_token = generate_access_token(encrypt=False, scope="openid") 192 | 193 | response = await test_client.get( 194 | "/authenticated", headers={"Authorization": f"Bearer {access_token}"} 195 | ) 196 | 197 | assert response.status_code == status.HTTP_200_OK 198 | 199 | json = response.json() 200 | assert json == { 201 | "id": user_id, 202 | "scope": ["openid"], 203 | "acr": FiefACR.LEVEL_ZERO, 204 | "permissions": [], 205 | "access_token": access_token, 206 | } 207 | 208 | async def test_optional( 209 | self, test_client: httpx.AsyncClient, generate_access_token, user_id: str 210 | ): 211 | response = await test_client.get("/authenticated-optional") 212 | assert response.status_code == status.HTTP_200_OK 213 | assert response.json() is None 214 | 215 | expired_access_token = generate_access_token( 216 | encrypt=False, scope="openid", exp=0 217 | ) 218 | response = await test_client.get( 219 | "/authenticated-optional", 220 | headers={"Authorization": f"Bearer {expired_access_token}"}, 221 | ) 222 | assert response.status_code == status.HTTP_200_OK 223 | assert response.json() is None 224 | 225 | access_token = generate_access_token(encrypt=False, scope="openid") 226 | response = await test_client.get( 227 | "/authenticated-optional", 228 | headers={"Authorization": f"Bearer {access_token}"}, 229 | ) 230 | assert response.status_code == status.HTTP_200_OK 231 | assert response.json() == { 232 | "id": user_id, 233 | "scope": ["openid"], 234 | "acr": FiefACR.LEVEL_ZERO, 235 | "permissions": [], 236 | "access_token": access_token, 237 | } 238 | 239 | async def test_missing_scope( 240 | self, test_client: httpx.AsyncClient, generate_access_token 241 | ): 242 | access_token = generate_access_token(encrypt=False, scope="openid") 243 | 244 | response = await test_client.get( 245 | "/authenticated-scope", headers={"Authorization": f"Bearer {access_token}"} 246 | ) 247 | 248 | assert response.status_code == status.HTTP_403_FORBIDDEN 249 | 250 | async def test_valid_scope( 251 | self, test_client: httpx.AsyncClient, generate_access_token, user_id: str 252 | ): 253 | access_token = generate_access_token( 254 | encrypt=False, scope="openid required_scope" 255 | ) 256 | 257 | response = await test_client.get( 258 | "/authenticated-scope", headers={"Authorization": f"Bearer {access_token}"} 259 | ) 260 | 261 | assert response.status_code == status.HTTP_200_OK 262 | 263 | json = response.json() 264 | assert json == { 265 | "id": user_id, 266 | "scope": ["openid", "required_scope"], 267 | "acr": FiefACR.LEVEL_ZERO, 268 | "permissions": [], 269 | "access_token": access_token, 270 | } 271 | 272 | async def test_invalid_acr( 273 | self, test_client: httpx.AsyncClient, generate_access_token 274 | ): 275 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ZERO) 276 | 277 | response = await test_client.get( 278 | "/authenticated-acr", headers={"Authorization": f"Bearer {access_token}"} 279 | ) 280 | 281 | assert response.status_code == status.HTTP_403_FORBIDDEN 282 | 283 | async def test_valid_acr( 284 | self, test_client: httpx.AsyncClient, generate_access_token, user_id: str 285 | ): 286 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ONE) 287 | 288 | response = await test_client.get( 289 | "/authenticated-acr", headers={"Authorization": f"Bearer {access_token}"} 290 | ) 291 | 292 | assert response.status_code == status.HTTP_200_OK 293 | 294 | json = response.json() 295 | assert json == { 296 | "id": user_id, 297 | "scope": [], 298 | "acr": FiefACR.LEVEL_ONE, 299 | "permissions": [], 300 | "access_token": access_token, 301 | } 302 | 303 | async def test_missing_permission( 304 | self, test_client: httpx.AsyncClient, generate_access_token 305 | ): 306 | access_token = generate_access_token( 307 | encrypt=False, permissions=["castles:read"] 308 | ) 309 | 310 | response = await test_client.get( 311 | "/authenticated-permission", 312 | headers={"Authorization": f"Bearer {access_token}"}, 313 | ) 314 | 315 | assert response.status_code == status.HTTP_403_FORBIDDEN 316 | 317 | async def test_valid_permission( 318 | self, test_client: httpx.AsyncClient, generate_access_token, user_id: str 319 | ): 320 | access_token = generate_access_token( 321 | encrypt=False, permissions=["castles:read", "castles:create"] 322 | ) 323 | 324 | response = await test_client.get( 325 | "/authenticated-permission", 326 | headers={"Authorization": f"Bearer {access_token}"}, 327 | ) 328 | 329 | assert response.status_code == status.HTTP_200_OK 330 | 331 | json = response.json() 332 | assert json == { 333 | "id": user_id, 334 | "scope": [], 335 | "acr": FiefACR.LEVEL_ZERO, 336 | "permissions": ["castles:read", "castles:create"], 337 | "access_token": access_token, 338 | } 339 | 340 | 341 | @pytest.mark.asyncio 342 | class TestCurrentUser: 343 | async def test_missing_token(self, test_client: httpx.AsyncClient): 344 | response = await test_client.get("/current-user") 345 | 346 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 347 | 348 | async def test_expired_token( 349 | self, test_client: httpx.AsyncClient, generate_access_token 350 | ): 351 | access_token = generate_access_token(encrypt=False, exp=0) 352 | 353 | response = await test_client.get( 354 | "/current-user", headers={"Authorization": f"Bearer {access_token}"} 355 | ) 356 | 357 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 358 | 359 | async def test_valid_token( 360 | self, 361 | test_client: httpx.AsyncClient, 362 | generate_access_token, 363 | mock_api_requests: respx.MockRouter, 364 | user_id: str, 365 | ): 366 | mock_api_requests.get("/userinfo").reset() 367 | mock_api_requests.get("/userinfo").return_value = Response( 368 | 200, json={"sub": user_id} 369 | ) 370 | 371 | access_token = generate_access_token(encrypt=False, scope="openid") 372 | 373 | response = await test_client.get( 374 | "/current-user", headers={"Authorization": f"Bearer {access_token}"} 375 | ) 376 | 377 | assert response.status_code == status.HTTP_200_OK 378 | 379 | json = response.json() 380 | assert json == {"sub": user_id} 381 | 382 | # Check cache is working 383 | response_2 = await test_client.get( 384 | "/current-user", headers={"Authorization": f"Bearer {access_token}"} 385 | ) 386 | 387 | assert response_2.status_code == status.HTTP_200_OK 388 | 389 | json = response_2.json() 390 | assert json == {"sub": user_id} 391 | 392 | assert mock_api_requests.get("/userinfo").call_count == 1 393 | 394 | async def test_optional( 395 | self, 396 | test_client: httpx.AsyncClient, 397 | generate_access_token, 398 | mock_api_requests: respx.MockRouter, 399 | user_id: str, 400 | ): 401 | mock_api_requests.get("/userinfo").reset() 402 | mock_api_requests.get("/userinfo").return_value = Response( 403 | 200, json={"sub": user_id} 404 | ) 405 | 406 | response = await test_client.get("/current-user-optional") 407 | assert response.status_code == status.HTTP_200_OK 408 | assert response.json() is None 409 | 410 | expired_access_token = generate_access_token( 411 | encrypt=False, scope="openid", exp=0 412 | ) 413 | response = await test_client.get( 414 | "/current-user-optional", 415 | headers={"Authorization": f"Bearer {expired_access_token}"}, 416 | ) 417 | assert response.status_code == status.HTTP_200_OK 418 | assert response.json() is None 419 | 420 | access_token = generate_access_token(encrypt=False, scope="openid") 421 | response = await test_client.get( 422 | "/current-user-optional", 423 | headers={"Authorization": f"Bearer {access_token}"}, 424 | ) 425 | assert response.status_code == status.HTTP_200_OK 426 | assert response.json() == {"sub": user_id} 427 | 428 | async def test_missing_scope( 429 | self, test_client: httpx.AsyncClient, generate_access_token 430 | ): 431 | access_token = generate_access_token(encrypt=False, scope="openid") 432 | 433 | response = await test_client.get( 434 | "/current-user-scope", headers={"Authorization": f"Bearer {access_token}"} 435 | ) 436 | 437 | assert response.status_code == status.HTTP_403_FORBIDDEN 438 | 439 | async def test_valid_scope( 440 | self, 441 | test_client: httpx.AsyncClient, 442 | generate_access_token, 443 | mock_api_requests: respx.MockRouter, 444 | user_id: str, 445 | ): 446 | mock_api_requests.get("/userinfo").return_value = Response( 447 | 200, json={"sub": user_id} 448 | ) 449 | 450 | access_token = generate_access_token( 451 | encrypt=False, scope="openid required_scope" 452 | ) 453 | 454 | response = await test_client.get( 455 | "/current-user-scope", headers={"Authorization": f"Bearer {access_token}"} 456 | ) 457 | 458 | assert response.status_code == status.HTTP_200_OK 459 | 460 | json = response.json() 461 | assert json == {"sub": user_id} 462 | 463 | async def test_missing_acr( 464 | self, test_client: httpx.AsyncClient, generate_access_token 465 | ): 466 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ZERO) 467 | 468 | response = await test_client.get( 469 | "/current-user-acr", headers={"Authorization": f"Bearer {access_token}"} 470 | ) 471 | 472 | assert response.status_code == status.HTTP_403_FORBIDDEN 473 | 474 | async def test_valid_acr( 475 | self, 476 | test_client: httpx.AsyncClient, 477 | generate_access_token, 478 | mock_api_requests: respx.MockRouter, 479 | user_id: str, 480 | ): 481 | mock_api_requests.get("/userinfo").return_value = Response( 482 | 200, json={"sub": user_id} 483 | ) 484 | 485 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ONE) 486 | 487 | response = await test_client.get( 488 | "/current-user-acr", headers={"Authorization": f"Bearer {access_token}"} 489 | ) 490 | 491 | assert response.status_code == status.HTTP_200_OK 492 | 493 | json = response.json() 494 | assert json == {"sub": user_id} 495 | 496 | async def test_missing_permission( 497 | self, test_client: httpx.AsyncClient, generate_access_token 498 | ): 499 | access_token = generate_access_token( 500 | encrypt=False, permissions=["castles:read"] 501 | ) 502 | 503 | response = await test_client.get( 504 | "/current-user-permission", 505 | headers={"Authorization": f"Bearer {access_token}"}, 506 | ) 507 | 508 | assert response.status_code == status.HTTP_403_FORBIDDEN 509 | 510 | async def test_valid_permission( 511 | self, test_client: httpx.AsyncClient, generate_access_token, user_id: str 512 | ): 513 | access_token = generate_access_token( 514 | encrypt=False, permissions=["castles:read", "castles:create"] 515 | ) 516 | 517 | response = await test_client.get( 518 | "/current-user-permission", 519 | headers={"Authorization": f"Bearer {access_token}"}, 520 | ) 521 | 522 | assert response.status_code == status.HTTP_200_OK 523 | 524 | json = response.json() 525 | assert json == {"sub": user_id} 526 | 527 | async def test_valid_refresh( 528 | self, 529 | test_client: httpx.AsyncClient, 530 | generate_access_token, 531 | mock_api_requests: respx.MockRouter, 532 | user_id: str, 533 | ): 534 | mock_api_requests.get("/userinfo").reset() 535 | mock_api_requests.get("/userinfo").return_value = Response( 536 | 200, json={"sub": user_id} 537 | ) 538 | 539 | access_token = generate_access_token(encrypt=False, scope="openid") 540 | 541 | response = await test_client.get( 542 | "/current-user-refresh", headers={"Authorization": f"Bearer {access_token}"} 543 | ) 544 | 545 | assert response.status_code == status.HTTP_200_OK 546 | 547 | json = response.json() 548 | assert json == {"sub": user_id} 549 | 550 | # Check cache is not used with refresh 551 | response_2 = await test_client.get( 552 | "/current-user-refresh", headers={"Authorization": f"Bearer {access_token}"} 553 | ) 554 | 555 | assert response_2.status_code == status.HTTP_200_OK 556 | 557 | json = response_2.json() 558 | assert json == {"sub": user_id} 559 | 560 | assert mock_api_requests.get("/userinfo").call_count == 2 561 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | import uuid 4 | from collections.abc import Mapping 5 | from typing import Optional 6 | 7 | import httpx 8 | import pytest 9 | import respx 10 | from httpx import Response 11 | from jwcrypto import jwk 12 | from pytest_mock import MockerFixture 13 | 14 | from fief_client.client import ( 15 | Fief, 16 | FiefAccessTokenACRTooLow, 17 | FiefAccessTokenExpired, 18 | FiefAccessTokenInvalid, 19 | FiefAccessTokenMissingPermission, 20 | FiefAccessTokenMissingScope, 21 | FiefACR, 22 | FiefAsync, 23 | FiefIdTokenInvalid, 24 | FiefRequestError, 25 | FiefTokenResponse, 26 | ) 27 | from fief_client.crypto import get_validation_hash 28 | from tests.conftest import GetAPIRequestsMock 29 | 30 | 31 | @pytest.fixture(scope="module") 32 | def fief_client() -> Fief: 33 | return Fief("https://bretagne.fief.dev", "CLIENT_ID", "CLIENT_SECRET") 34 | 35 | 36 | @pytest.fixture(scope="module") 37 | def fief_client_tenant() -> Fief: 38 | return Fief("https://bretagne.fief.dev/secondary", "CLIENT_ID", "CLIENT_SECRET") 39 | 40 | 41 | @pytest.fixture(scope="module") 42 | def fief_client_encryption_key(encryption_key: jwk.JWK) -> Fief: 43 | return Fief( 44 | "https://bretagne.fief.dev", 45 | "CLIENT_ID", 46 | "CLIENT_SECRET", 47 | encryption_key=encryption_key.export(), 48 | ) 49 | 50 | 51 | @pytest.fixture(scope="module") 52 | def fief_async_client() -> FiefAsync: 53 | return FiefAsync("https://bretagne.fief.dev", "CLIENT_ID", "CLIENT_SECRET") 54 | 55 | 56 | def test_serializable_fief_token_response(): 57 | token_response = FiefTokenResponse( 58 | access_token="ACCESS_TOKEN", 59 | id_token="ID_TOKEN", 60 | token_type="bearer", 61 | expires_in=3600, 62 | refresh_token=None, 63 | ) 64 | assert ( 65 | json.dumps(token_response) 66 | == '{"access_token": "ACCESS_TOKEN", "id_token": "ID_TOKEN", "token_type": "bearer", "expires_in": 3600, "refresh_token": null}' 67 | ) 68 | 69 | 70 | def test_fief_acr(): 71 | assert FiefACR.LEVEL_ZERO < FiefACR.LEVEL_ONE 72 | assert FiefACR.LEVEL_ZERO <= FiefACR.LEVEL_ONE 73 | assert FiefACR.LEVEL_ONE > FiefACR.LEVEL_ZERO 74 | assert FiefACR.LEVEL_ONE >= FiefACR.LEVEL_ZERO 75 | assert FiefACR.LEVEL_ZERO == FiefACR.LEVEL_ZERO 76 | 77 | 78 | class TestCustomVerifyCertParameters: 79 | def test_sync(self, mocker: MockerFixture): 80 | client_mock = mocker.patch.object(httpx, "Client") 81 | 82 | fief = Fief( 83 | "https://bretagne.fief.dev", 84 | "CLIENT_ID", 85 | "CLIENT_SECRET", 86 | verify=False, 87 | cert="/bretagne.pem", 88 | ) 89 | with fief._get_httpx_client() as _: 90 | client_mock.assert_called_with( 91 | base_url="https://bretagne.fief.dev", 92 | headers={}, 93 | verify=False, 94 | cert="/bretagne.pem", 95 | ) 96 | 97 | @pytest.mark.asyncio 98 | async def test_async(self, mocker: MockerFixture): 99 | client_mock = mocker.patch.object(httpx, "AsyncClient") 100 | 101 | @contextlib.asynccontextmanager 102 | async def client_context_mock(*args, **kwargs): 103 | yield 104 | 105 | client_mock.side_effect = client_context_mock 106 | 107 | fief = FiefAsync( 108 | "https://bretagne.fief.dev", 109 | "CLIENT_ID", 110 | "CLIENT_SECRET", 111 | verify=False, 112 | cert="/bretagne.pem", 113 | ) 114 | async with fief._get_httpx_client() as _: 115 | client_mock.assert_called_with( 116 | base_url="https://bretagne.fief.dev", 117 | headers={}, 118 | verify=False, 119 | cert="/bretagne.pem", 120 | ) 121 | 122 | 123 | class TestAuthURL: 124 | @pytest.mark.parametrize( 125 | "state,scope,code_challenge,code_challenge_method,lang,extras_params,expected_params", 126 | [ 127 | (None, None, None, None, None, None, ""), 128 | ("STATE", None, None, None, None, None, "&state=STATE"), 129 | ( 130 | None, 131 | ["SCOPE_1", "SCOPE_2"], 132 | None, 133 | None, 134 | None, 135 | None, 136 | "&scope=SCOPE_1+SCOPE_2", 137 | ), 138 | (None, None, None, None, None, {"foo": "bar"}, "&foo=bar"), 139 | ( 140 | None, 141 | None, 142 | "CODE_CHALLENGE", 143 | "S256", 144 | None, 145 | None, 146 | "&code_challenge=CODE_CHALLENGE&code_challenge_method=S256", 147 | ), 148 | (None, None, None, None, "fr-FR", None, "&lang=fr-FR"), 149 | ], 150 | ) 151 | def test_authorization_url( 152 | self, 153 | state: Optional[str], 154 | scope: Optional[list[str]], 155 | code_challenge: Optional[str], 156 | code_challenge_method: Optional[str], 157 | lang: Optional[str], 158 | extras_params: Optional[Mapping[str, str]], 159 | expected_params: str, 160 | fief_client: Fief, 161 | mock_api_requests: respx.MockRouter, 162 | ): 163 | authorize_url = fief_client.auth_url( 164 | "https://www.bretagne.duchy/callback", 165 | state=state, 166 | scope=scope, 167 | code_challenge=code_challenge, 168 | code_challenge_method=code_challenge_method, 169 | lang=lang, 170 | extras_params=extras_params, 171 | ) 172 | assert ( 173 | authorize_url 174 | == f"https://bretagne.fief.dev/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=https%3A%2F%2Fwww.bretagne.duchy%2Fcallback{expected_params}" 175 | ) 176 | 177 | assert mock_api_requests.calls.last is not None 178 | request, _ = mock_api_requests.calls.last 179 | url = str(request.url) 180 | assert url.startswith(fief_client.base_url) 181 | 182 | assert request.url.host == request.headers["Host"] 183 | 184 | @pytest.mark.asyncio 185 | @pytest.mark.parametrize( 186 | "state,scope,code_challenge,code_challenge_method,lang,extras_params,expected_params", 187 | [ 188 | (None, None, None, None, None, None, ""), 189 | ("STATE", None, None, None, None, None, "&state=STATE"), 190 | ( 191 | None, 192 | ["SCOPE_1", "SCOPE_2"], 193 | None, 194 | None, 195 | None, 196 | None, 197 | "&scope=SCOPE_1+SCOPE_2", 198 | ), 199 | (None, None, None, None, None, {"foo": "bar"}, "&foo=bar"), 200 | ( 201 | None, 202 | None, 203 | "CODE_CHALLENGE", 204 | "S256", 205 | None, 206 | None, 207 | "&code_challenge=CODE_CHALLENGE&code_challenge_method=S256", 208 | ), 209 | (None, None, None, None, "fr-FR", None, "&lang=fr-FR"), 210 | ], 211 | ) 212 | async def test_authorization_url_async( 213 | self, 214 | state: Optional[str], 215 | scope: Optional[list[str]], 216 | code_challenge: Optional[str], 217 | code_challenge_method: Optional[str], 218 | lang: Optional[str], 219 | extras_params: Optional[Mapping[str, str]], 220 | expected_params: str, 221 | fief_async_client: FiefAsync, 222 | mock_api_requests: respx.MockRouter, 223 | ): 224 | authorize_url = await fief_async_client.auth_url( 225 | "https://www.bretagne.duchy/callback", 226 | state=state, 227 | scope=scope, 228 | code_challenge=code_challenge, 229 | code_challenge_method=code_challenge_method, 230 | lang=lang, 231 | extras_params=extras_params, 232 | ) 233 | assert ( 234 | authorize_url 235 | == f"https://bretagne.fief.dev/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=https%3A%2F%2Fwww.bretagne.duchy%2Fcallback{expected_params}" 236 | ) 237 | 238 | assert mock_api_requests.calls.last is not None 239 | request, _ = mock_api_requests.calls.last 240 | url = str(request.url) 241 | assert url.startswith(fief_async_client.base_url) 242 | 243 | assert request.url.host == request.headers["Host"] 244 | 245 | def test_authorization_url_tenant( 246 | self, fief_client_tenant: Fief, get_api_requests_mock: GetAPIRequestsMock 247 | ): 248 | with get_api_requests_mock(path_prefix="/secondary"): 249 | authorize_url = fief_client_tenant.auth_url( 250 | "https://www.bretagne.duchy/callback" 251 | ) 252 | 253 | assert ( 254 | authorize_url 255 | == "https://bretagne.fief.dev/secondary/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=https%3A%2F%2Fwww.bretagne.duchy%2Fcallback" 256 | ) 257 | 258 | 259 | class TestAuthCallback: 260 | def test_error_response( 261 | self, fief_client: Fief, mock_api_requests: respx.MockRouter 262 | ): 263 | token_route = mock_api_requests.post("/token") 264 | token_route.return_value = Response(400, json={"detail": "error"}) 265 | 266 | with pytest.raises(FiefRequestError) as excinfo: 267 | fief_client.auth_callback( 268 | "CODE", 269 | "https://www.bretagne.duchy/callback", 270 | code_verifier="CODE_VERIFIER", 271 | ) 272 | assert excinfo.value.status_code == 400 273 | assert excinfo.value.detail == '{"detail": "error"}' 274 | 275 | def test_valid_response( 276 | self, 277 | fief_client: Fief, 278 | mock_api_requests: respx.MockRouter, 279 | access_token: str, 280 | signed_id_token: str, 281 | user_id: str, 282 | ): 283 | token_route = mock_api_requests.post("/token") 284 | token_route.return_value = Response( 285 | 200, 286 | json={ 287 | "access_token": access_token, 288 | "id_token": signed_id_token, 289 | "token_type": "bearer", 290 | }, 291 | ) 292 | 293 | token_response, userinfo = fief_client.auth_callback( 294 | "CODE", "https://www.bretagne.duchy/callback", code_verifier="CODE_VERIFIER" 295 | ) 296 | 297 | token_route_call = token_route.calls.last 298 | assert token_route_call is not None 299 | 300 | request_data = token_route_call.request.content.decode("utf-8") 301 | assert "client_id" in request_data 302 | assert "client_secret" in request_data 303 | 304 | assert token_response["access_token"] == access_token 305 | assert token_response["id_token"] == signed_id_token 306 | 307 | assert isinstance(userinfo, dict) 308 | assert userinfo["sub"] == user_id 309 | 310 | @pytest.mark.asyncio 311 | async def test_error_response_async( 312 | self, fief_async_client: FiefAsync, mock_api_requests: respx.MockRouter 313 | ): 314 | token_route = mock_api_requests.post("/token") 315 | token_route.return_value = Response(400, json={"detail": "error"}) 316 | 317 | with pytest.raises(FiefRequestError) as excinfo: 318 | await fief_async_client.auth_callback( 319 | "CODE", 320 | "https://www.bretagne.duchy/callback", 321 | code_verifier="CODE_VERIFIER", 322 | ) 323 | assert excinfo.value.status_code == 400 324 | assert excinfo.value.detail == '{"detail": "error"}' 325 | 326 | @pytest.mark.asyncio 327 | async def test_valid_response_async( 328 | self, 329 | fief_async_client: FiefAsync, 330 | mock_api_requests: respx.MockRouter, 331 | access_token: str, 332 | signed_id_token: str, 333 | user_id: str, 334 | ): 335 | token_route = mock_api_requests.post("/token") 336 | token_route.return_value = Response( 337 | 200, 338 | json={ 339 | "access_token": access_token, 340 | "id_token": signed_id_token, 341 | "token_type": "bearer", 342 | }, 343 | ) 344 | 345 | token_response, userinfo = await fief_async_client.auth_callback( 346 | "CODE", "https://www.bretagne.duchy/callback", code_verifier="CODE_VERIFIER" 347 | ) 348 | 349 | token_route_call = token_route.calls.last 350 | assert token_route_call is not None 351 | 352 | request_data = token_route_call.request.content.decode("utf-8") 353 | assert "client_id" in request_data 354 | assert "client_secret" in request_data 355 | 356 | assert token_response["access_token"] == access_token 357 | assert token_response["id_token"] == signed_id_token 358 | 359 | assert isinstance(userinfo, dict) 360 | assert userinfo["sub"] == user_id 361 | 362 | def test_valid_response_tenant( 363 | self, 364 | fief_client_tenant: Fief, 365 | get_api_requests_mock: GetAPIRequestsMock, 366 | access_token: str, 367 | signed_id_token: str, 368 | user_id: str, 369 | ): 370 | with get_api_requests_mock(path_prefix="/secondary") as mock_api_requests: 371 | token_route = mock_api_requests.post("/secondary/token") 372 | token_route.return_value = Response( 373 | 200, 374 | json={ 375 | "access_token": access_token, 376 | "id_token": signed_id_token, 377 | "token_type": "bearer", 378 | }, 379 | ) 380 | 381 | token_response, userinfo = fief_client_tenant.auth_callback( 382 | "CODE", 383 | "https://www.bretagne.duchy/callback", 384 | code_verifier="CODE_VERIFIER", 385 | ) 386 | 387 | token_route_call = token_route.calls.last 388 | assert token_route_call is not None 389 | 390 | request_data = token_route_call.request.content.decode("utf-8") 391 | assert "client_id" in request_data 392 | assert "client_secret" in request_data 393 | 394 | assert token_response["access_token"] == access_token 395 | assert token_response["id_token"] == signed_id_token 396 | 397 | assert isinstance(userinfo, dict) 398 | assert userinfo["sub"] == user_id 399 | 400 | def test_no_code_verifier( 401 | self, 402 | fief_client: Fief, 403 | mock_api_requests: respx.MockRouter, 404 | access_token: str, 405 | signed_id_token: str, 406 | user_id: str, 407 | ): 408 | token_route = mock_api_requests.post("/token") 409 | token_route.return_value = Response( 410 | 200, 411 | json={ 412 | "access_token": access_token, 413 | "id_token": signed_id_token, 414 | "token_type": "bearer", 415 | }, 416 | ) 417 | 418 | token_response, userinfo = fief_client.auth_callback( 419 | "CODE", "https://www.bretagne.duchy/callback" 420 | ) 421 | 422 | token_route_call = token_route.calls.last 423 | assert token_route_call is not None 424 | 425 | request_data = token_route_call.request.content.decode("utf-8") 426 | assert "client_id" in request_data 427 | assert "client_secret" in request_data 428 | assert "code_verifier" not in request_data 429 | 430 | 431 | class TestAuthRefreshToken: 432 | def test_error_response( 433 | self, fief_client: Fief, mock_api_requests: respx.MockRouter 434 | ): 435 | token_route = mock_api_requests.post("/token") 436 | token_route.return_value = Response(400, json={"detail": "error"}) 437 | 438 | with pytest.raises(FiefRequestError) as excinfo: 439 | fief_client.auth_refresh_token( 440 | "REFRESH_TOKEN", scope=["openid", "offline_access"] 441 | ) 442 | assert excinfo.value.status_code == 400 443 | assert excinfo.value.detail == '{"detail": "error"}' 444 | 445 | def test_valid_response( 446 | self, 447 | fief_client: Fief, 448 | mock_api_requests: respx.MockRouter, 449 | access_token: str, 450 | signed_id_token: str, 451 | user_id: str, 452 | ): 453 | token_route = mock_api_requests.post("/token") 454 | token_route.return_value = Response( 455 | 200, 456 | json={ 457 | "access_token": access_token, 458 | "id_token": signed_id_token, 459 | "token_type": "bearer", 460 | }, 461 | ) 462 | 463 | token_response, userinfo = fief_client.auth_refresh_token( 464 | "REFRESH_TOKEN", scope=["openid", "offline_access"] 465 | ) 466 | 467 | token_route_call = token_route.calls.last 468 | assert token_route_call is not None 469 | 470 | request_data = token_route_call.request.content.decode("utf-8") 471 | assert "client_id" in request_data 472 | assert "client_secret" in request_data 473 | 474 | assert token_response["access_token"] == access_token 475 | assert token_response["id_token"] == signed_id_token 476 | 477 | assert isinstance(userinfo, dict) 478 | assert userinfo["sub"] == user_id 479 | 480 | @pytest.mark.asyncio 481 | async def test_error_response_async( 482 | self, fief_async_client: FiefAsync, mock_api_requests: respx.MockRouter 483 | ): 484 | token_route = mock_api_requests.post("/token") 485 | token_route.return_value = Response(400, json={"detail": "error"}) 486 | 487 | with pytest.raises(FiefRequestError) as excinfo: 488 | await fief_async_client.auth_refresh_token( 489 | "REFRESH_TOKEN", scope=["openid", "offline_access"] 490 | ) 491 | assert excinfo.value.status_code == 400 492 | assert excinfo.value.detail == '{"detail": "error"}' 493 | 494 | @pytest.mark.asyncio 495 | async def test_valid_response_async( 496 | self, 497 | fief_async_client: FiefAsync, 498 | mock_api_requests: respx.MockRouter, 499 | access_token: str, 500 | signed_id_token: str, 501 | user_id: str, 502 | ): 503 | token_route = mock_api_requests.post("/token") 504 | token_route.return_value = Response( 505 | 200, 506 | json={ 507 | "access_token": access_token, 508 | "id_token": signed_id_token, 509 | "token_type": "bearer", 510 | }, 511 | ) 512 | 513 | token_response, userinfo = await fief_async_client.auth_refresh_token( 514 | "REFRESH_TOKEN", scope=["openid", "offline_access"] 515 | ) 516 | 517 | token_route_call = token_route.calls.last 518 | assert token_route_call is not None 519 | 520 | request_data = token_route_call.request.content.decode("utf-8") 521 | assert "client_id" in request_data 522 | assert "client_secret" in request_data 523 | 524 | assert token_response["access_token"] == access_token 525 | assert token_response["id_token"] == signed_id_token 526 | 527 | assert isinstance(userinfo, dict) 528 | assert userinfo["sub"] == user_id 529 | 530 | 531 | class TestValidateAccessToken: 532 | def test_invalid_token(self, fief_client: Fief): 533 | with pytest.raises(FiefAccessTokenInvalid): 534 | fief_client.validate_access_token("INVALID_TOKEN") 535 | 536 | def test_invalid_signature(self, fief_client: Fief): 537 | with pytest.raises(FiefAccessTokenInvalid): 538 | fief_client.validate_access_token( 539 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 540 | ) 541 | 542 | def test_invalid_claims(self, fief_client: Fief): 543 | with pytest.raises(FiefAccessTokenInvalid): 544 | fief_client.validate_access_token( 545 | "eyJhbGciOiJSUzI1NiJ9.e30.RmKxjgPljzJL_-Yp9oBJIvNejvES_pnTeZBDvptYcdWm4Ze9D6FlM8RFJ5-ZJ3O-HXlWylVXiGAE_wdSGXehSaENUN3Mj91j5OfiXGrtBGSiEiCtC9HYKCi6xf6xmcEPoTbtBVi38a9OARoJlpTJ5T4BbmqIUR8R06sqo3zTkwk48wPmYtk_OPgMv4c8tNyHF17dRe1JM_ix-m7V1Nv_2DHLMRgMXdsWkl0RCcAFQwqCTXU4UxWSoXp6CB0-Ybkq-P5KyXIXy0b15qG8jfgCrFHqFhN3hpyvL4Zza_EkXJaCkB5v-oztlHS6gTGb3QgFqppW3JM6TJnDKslGRPDsjg" 546 | ) 547 | 548 | def test_invalid_acr_claim(self, fief_client: Fief, generate_access_token): 549 | access_token = generate_access_token(encrypt=False, acr="INVALID_ACR") 550 | with pytest.raises(FiefAccessTokenInvalid): 551 | fief_client.validate_access_token(access_token) 552 | 553 | def test_expired(self, fief_client: Fief, generate_access_token): 554 | access_token = generate_access_token(encrypt=False, exp=0) 555 | with pytest.raises(FiefAccessTokenExpired): 556 | fief_client.validate_access_token(access_token) 557 | 558 | def test_missing_scope(self, fief_client: Fief, generate_access_token): 559 | access_token = generate_access_token( 560 | encrypt=False, scope="openid offline_access" 561 | ) 562 | with pytest.raises(FiefAccessTokenMissingScope): 563 | fief_client.validate_access_token(access_token, required_scope=["REQUIRED"]) 564 | 565 | def test_valid_scope(self, fief_client: Fief, generate_access_token, user_id: str): 566 | access_token = generate_access_token( 567 | encrypt=False, scope="openid offline_access" 568 | ) 569 | info = fief_client.validate_access_token( 570 | access_token, required_scope=["openid"] 571 | ) 572 | assert info == { 573 | "id": uuid.UUID(user_id), 574 | "scope": ["openid", "offline_access"], 575 | "acr": FiefACR.LEVEL_ZERO, 576 | "permissions": [], 577 | "access_token": access_token, 578 | } 579 | 580 | def test_invalid_acr(self, fief_client: Fief, generate_access_token): 581 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ZERO) 582 | with pytest.raises(FiefAccessTokenACRTooLow): 583 | fief_client.validate_access_token( 584 | access_token, required_acr=FiefACR.LEVEL_ONE 585 | ) 586 | 587 | def test_valid_acr(self, fief_client: Fief, generate_access_token, user_id: str): 588 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ONE) 589 | info = fief_client.validate_access_token( 590 | access_token, required_acr=FiefACR.LEVEL_ONE 591 | ) 592 | assert info == { 593 | "id": uuid.UUID(user_id), 594 | "scope": [], 595 | "acr": FiefACR.LEVEL_ONE, 596 | "permissions": [], 597 | "access_token": access_token, 598 | } 599 | 600 | def test_missing_permission(self, fief_client: Fief, generate_access_token): 601 | access_token = generate_access_token( 602 | encrypt=False, permissions=["castles:read"] 603 | ) 604 | with pytest.raises(FiefAccessTokenMissingPermission): 605 | fief_client.validate_access_token( 606 | access_token, required_permissions=["castles:create"] 607 | ) 608 | 609 | def test_valid_permission( 610 | self, fief_client: Fief, generate_access_token, user_id: str 611 | ): 612 | access_token = generate_access_token( 613 | encrypt=False, permissions=["castles:read", "castles:create"] 614 | ) 615 | info = fief_client.validate_access_token( 616 | access_token, required_permissions=["castles:create"] 617 | ) 618 | assert info == { 619 | "id": uuid.UUID(user_id), 620 | "scope": [], 621 | "acr": FiefACR.LEVEL_ZERO, 622 | "permissions": ["castles:read", "castles:create"], 623 | "access_token": access_token, 624 | } 625 | 626 | @pytest.mark.asyncio 627 | async def test_async_invalid_token(self, fief_async_client: FiefAsync): 628 | with pytest.raises(FiefAccessTokenInvalid): 629 | await fief_async_client.validate_access_token("INVALID_TOKEN") 630 | 631 | @pytest.mark.asyncio 632 | async def test_async_invalid_signature(self, fief_async_client: FiefAsync): 633 | with pytest.raises(FiefAccessTokenInvalid): 634 | await fief_async_client.validate_access_token( 635 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 636 | ) 637 | 638 | @pytest.mark.asyncio 639 | async def test_async_invalid_claims(self, fief_async_client: FiefAsync): 640 | with pytest.raises(FiefAccessTokenInvalid): 641 | await fief_async_client.validate_access_token( 642 | "eyJhbGciOiJSUzI1NiJ9.e30.RmKxjgPljzJL_-Yp9oBJIvNejvES_pnTeZBDvptYcdWm4Ze9D6FlM8RFJ5-ZJ3O-HXlWylVXiGAE_wdSGXehSaENUN3Mj91j5OfiXGrtBGSiEiCtC9HYKCi6xf6xmcEPoTbtBVi38a9OARoJlpTJ5T4BbmqIUR8R06sqo3zTkwk48wPmYtk_OPgMv4c8tNyHF17dRe1JM_ix-m7V1Nv_2DHLMRgMXdsWkl0RCcAFQwqCTXU4UxWSoXp6CB0-Ybkq-P5KyXIXy0b15qG8jfgCrFHqFhN3hpyvL4Zza_EkXJaCkB5v-oztlHS6gTGb3QgFqppW3JM6TJnDKslGRPDsjg" 643 | ) 644 | 645 | @pytest.mark.asyncio 646 | async def test_async_expired( 647 | self, fief_async_client: FiefAsync, generate_access_token 648 | ): 649 | access_token = generate_access_token(encrypt=False, exp=0) 650 | with pytest.raises(FiefAccessTokenExpired): 651 | await fief_async_client.validate_access_token(access_token) 652 | 653 | @pytest.mark.asyncio 654 | async def test_async_missing_scope( 655 | self, fief_async_client: FiefAsync, generate_access_token 656 | ): 657 | access_token = generate_access_token( 658 | encrypt=False, scope="openid offline_access" 659 | ) 660 | with pytest.raises(FiefAccessTokenMissingScope): 661 | await fief_async_client.validate_access_token( 662 | access_token, required_scope=["REQUIRED"] 663 | ) 664 | 665 | @pytest.mark.asyncio 666 | async def test_async_valid_scope( 667 | self, fief_async_client: FiefAsync, generate_access_token, user_id: str 668 | ): 669 | access_token = generate_access_token( 670 | encrypt=False, scope="openid offline_access" 671 | ) 672 | info = await fief_async_client.validate_access_token( 673 | access_token, required_scope=["openid"] 674 | ) 675 | assert info == { 676 | "id": uuid.UUID(user_id), 677 | "scope": ["openid", "offline_access"], 678 | "acr": FiefACR.LEVEL_ZERO, 679 | "permissions": [], 680 | "access_token": access_token, 681 | } 682 | 683 | @pytest.mark.asyncio 684 | async def test_async_invalid_acr( 685 | self, fief_async_client: FiefAsync, generate_access_token 686 | ): 687 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ZERO) 688 | with pytest.raises(FiefAccessTokenACRTooLow): 689 | await fief_async_client.validate_access_token( 690 | access_token, required_acr=FiefACR.LEVEL_ONE 691 | ) 692 | 693 | @pytest.mark.asyncio 694 | async def test_async_valid_acr( 695 | self, fief_async_client: FiefAsync, generate_access_token, user_id: str 696 | ): 697 | access_token = generate_access_token(encrypt=False, acr=FiefACR.LEVEL_ONE) 698 | info = await fief_async_client.validate_access_token( 699 | access_token, required_acr=FiefACR.LEVEL_ONE 700 | ) 701 | assert info == { 702 | "id": uuid.UUID(user_id), 703 | "scope": [], 704 | "acr": FiefACR.LEVEL_ONE, 705 | "permissions": [], 706 | "access_token": access_token, 707 | } 708 | 709 | @pytest.mark.asyncio 710 | async def test_async_missing_permission( 711 | self, fief_async_client: FiefAsync, generate_access_token 712 | ): 713 | access_token = generate_access_token( 714 | encrypt=False, permissions=["castles:read"] 715 | ) 716 | with pytest.raises(FiefAccessTokenMissingPermission): 717 | await fief_async_client.validate_access_token( 718 | access_token, required_permissions=["castles:create"] 719 | ) 720 | 721 | @pytest.mark.asyncio 722 | async def test_async_valid_permission( 723 | self, fief_async_client: FiefAsync, generate_access_token, user_id: str 724 | ): 725 | access_token = generate_access_token( 726 | encrypt=False, permissions=["castles:read", "castles:create"] 727 | ) 728 | info = await fief_async_client.validate_access_token( 729 | access_token, required_permissions=["castles:create"] 730 | ) 731 | assert info == { 732 | "id": uuid.UUID(user_id), 733 | "scope": [], 734 | "acr": FiefACR.LEVEL_ZERO, 735 | "permissions": ["castles:read", "castles:create"], 736 | "access_token": access_token, 737 | } 738 | 739 | 740 | class TestUserinfo: 741 | def test_error_response( 742 | self, fief_client: Fief, mock_api_requests: respx.MockRouter 743 | ): 744 | token_route = mock_api_requests.get("/userinfo") 745 | token_route.return_value = Response(400, json={"detail": "error"}) 746 | 747 | with pytest.raises(FiefRequestError) as excinfo: 748 | fief_client.userinfo("ACCESS_TOKEN") 749 | assert excinfo.value.status_code == 400 750 | assert excinfo.value.detail == '{"detail": "error"}' 751 | 752 | def test_valid_response( 753 | self, fief_client: Fief, mock_api_requests: respx.MockRouter, user_id: str 754 | ): 755 | mock_api_requests.get("/userinfo").return_value = Response( 756 | 200, json={"sub": user_id} 757 | ) 758 | 759 | userinfo = fief_client.userinfo("ACCESS_TOKEN") 760 | assert userinfo == {"sub": user_id} 761 | 762 | @pytest.mark.asyncio 763 | async def test_error_response_async( 764 | self, fief_async_client: FiefAsync, mock_api_requests: respx.MockRouter 765 | ): 766 | token_route = mock_api_requests.get("/userinfo") 767 | token_route.return_value = Response(400, json={"detail": "error"}) 768 | 769 | with pytest.raises(FiefRequestError) as excinfo: 770 | await fief_async_client.userinfo("ACCESS_TOKEN") 771 | assert excinfo.value.status_code == 400 772 | assert excinfo.value.detail == '{"detail": "error"}' 773 | 774 | @pytest.mark.asyncio 775 | async def test_valid_response_async( 776 | self, 777 | fief_async_client: FiefAsync, 778 | mock_api_requests: respx.MockRouter, 779 | user_id: str, 780 | ): 781 | mock_api_requests.get("/userinfo").return_value = Response( 782 | 200, json={"sub": user_id} 783 | ) 784 | 785 | userinfo = await fief_async_client.userinfo("ACCESS_TOKEN") 786 | assert userinfo == {"sub": user_id} 787 | 788 | 789 | @pytest.mark.parametrize( 790 | "endpoint,method_name,args", 791 | [ 792 | ( 793 | "/api/profile", 794 | "update_profile", 795 | ("ACCESS_TOKEN", {"fields": {"first_name": "Anne"}}), 796 | ), 797 | ("/api/password", "change_password", ("ACCESS_TOKEN", "herminetincture")), 798 | ("/api/email/change", "email_change", ("ACCESS_TOKEN", "anne@nantes.city")), 799 | ("/api/email/verify", "email_verify", ("ACCESS_TOKEN", "ABCDE")), 800 | ], 801 | ) 802 | class TestUpdateUserMethods: 803 | def test_error_response( 804 | self, 805 | endpoint: str, 806 | method_name: str, 807 | args: tuple, 808 | fief_client: Fief, 809 | mock_api_requests: respx.MockRouter, 810 | ): 811 | route = mock_api_requests.route(path=endpoint) 812 | route.return_value = Response(400, json={"detail": "error"}) 813 | 814 | with pytest.raises(FiefRequestError) as excinfo: 815 | method = getattr(fief_client, method_name) 816 | method(*args) 817 | assert excinfo.value.status_code == 400 818 | assert excinfo.value.detail == '{"detail": "error"}' 819 | 820 | def test_valid_response( 821 | self, 822 | endpoint: str, 823 | method_name: str, 824 | args: tuple, 825 | fief_client: Fief, 826 | mock_api_requests: respx.MockRouter, 827 | user_id: str, 828 | ): 829 | mock_api_requests.route(path=endpoint).return_value = Response( 830 | 200, json={"sub": user_id} 831 | ) 832 | 833 | method = getattr(fief_client, method_name) 834 | userinfo = method(*args) 835 | assert userinfo == {"sub": user_id} 836 | 837 | @pytest.mark.asyncio 838 | async def test_error_response_async( 839 | self, 840 | endpoint: str, 841 | method_name: str, 842 | args: tuple, 843 | fief_async_client: FiefAsync, 844 | mock_api_requests: respx.MockRouter, 845 | ): 846 | route = mock_api_requests.route(path=endpoint) 847 | route.return_value = Response(400, json={"detail": "error"}) 848 | 849 | with pytest.raises(FiefRequestError) as excinfo: 850 | method = getattr(fief_async_client, method_name) 851 | await method(*args) 852 | assert excinfo.value.status_code == 400 853 | assert excinfo.value.detail == '{"detail": "error"}' 854 | 855 | @pytest.mark.asyncio 856 | async def test_valid_response_async( 857 | self, 858 | endpoint: str, 859 | method_name: str, 860 | args: tuple, 861 | fief_async_client: FiefAsync, 862 | mock_api_requests: respx.MockRouter, 863 | user_id: str, 864 | ): 865 | mock_api_requests.route(path=endpoint).return_value = Response( 866 | 200, json={"sub": user_id} 867 | ) 868 | 869 | method = getattr(fief_async_client, method_name) 870 | userinfo = await method(*args) 871 | assert userinfo == {"sub": user_id} 872 | 873 | 874 | class TestLogoutURL: 875 | def test_logout_url(self, fief_client: Fief): 876 | logout_url = fief_client.logout_url("https://www.bretagne.duchy") 877 | assert ( 878 | logout_url 879 | == "https://bretagne.fief.dev/logout?redirect_uri=https%3A%2F%2Fwww.bretagne.duchy" 880 | ) 881 | 882 | @pytest.mark.asyncio 883 | async def test_logout_url_async(self, fief_async_client: FiefAsync): 884 | logout_url = await fief_async_client.logout_url("https://www.bretagne.duchy") 885 | assert ( 886 | logout_url 887 | == "https://bretagne.fief.dev/logout?redirect_uri=https%3A%2F%2Fwww.bretagne.duchy" 888 | ) 889 | 890 | 891 | class TestDecodeIdToken: 892 | def test_signed_valid( 893 | self, 894 | fief_client: Fief, 895 | signed_id_token: str, 896 | signature_key: jwk.JWK, 897 | user_id: str, 898 | ): 899 | claims = fief_client._decode_id_token(signed_id_token, signature_key) 900 | assert claims["sub"] == user_id 901 | 902 | def test_signed_invalid(self, fief_client: Fief, signature_key: jwk.JWK): 903 | with pytest.raises(FiefIdTokenInvalid): 904 | fief_client._decode_id_token( 905 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 906 | signature_key, 907 | ) 908 | 909 | def test_encrypted_valid( 910 | self, 911 | fief_client_encryption_key: Fief, 912 | encrypted_id_token: str, 913 | signature_key: jwk.JWK, 914 | user_id: str, 915 | ): 916 | claims = fief_client_encryption_key._decode_id_token( 917 | encrypted_id_token, signature_key 918 | ) 919 | assert claims["sub"] == user_id 920 | 921 | def test_encrypted_without_key( 922 | self, fief_client: Fief, encrypted_id_token: str, signature_key: jwk.JWK 923 | ): 924 | with pytest.raises(FiefIdTokenInvalid): 925 | fief_client._decode_id_token(encrypted_id_token, signature_key) 926 | 927 | def test_encrypted_invalid( 928 | self, fief_client_encryption_key: Fief, signature_key: jwk.JWK 929 | ): 930 | with pytest.raises(FiefIdTokenInvalid): 931 | fief_client_encryption_key._decode_id_token( 932 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 933 | signature_key, 934 | ) 935 | 936 | def test_signed_at_hash_c_hash_valid( 937 | self, 938 | fief_client: Fief, 939 | signature_key: jwk.JWK, 940 | user_id: str, 941 | generate_token, 942 | ): 943 | id_token = generate_token( 944 | encrypt=False, 945 | c_hash=get_validation_hash("CODE"), 946 | at_hash=get_validation_hash("ACCESS_TOKEN"), 947 | ) 948 | claims = fief_client._decode_id_token( 949 | id_token, signature_key, code="CODE", access_token="ACCESS_TOKEN" 950 | ) 951 | assert claims["sub"] == user_id 952 | 953 | @pytest.mark.parametrize( 954 | "claims", 955 | [ 956 | {"c_hash": get_validation_hash("INVALID_CODE")}, 957 | {"at_hash": get_validation_hash("INVALID_ACCESS_TOKEN")}, 958 | { 959 | "c_hash": get_validation_hash("INVALID_CODE"), 960 | "at_hash": get_validation_hash("INVALID_ACCESS_TOKEN"), 961 | }, 962 | ], 963 | ) 964 | def test_signed_at_hash_c_hash_invalid( 965 | self, 966 | claims: dict[str, str], 967 | fief_client: Fief, 968 | signature_key: jwk.JWK, 969 | generate_token, 970 | ): 971 | id_token = generate_token(encrypt=False, **claims) 972 | with pytest.raises(FiefIdTokenInvalid): 973 | fief_client._decode_id_token( 974 | id_token, signature_key, code="CODE", access_token="ACCESS_TOKEN" 975 | ) 976 | 977 | 978 | class TestExplicitHost: 979 | def test_sync_client(self, mock_api_requests: respx.MockRouter): 980 | client = Fief( 981 | "http://localhost:8000", 982 | "CLIENT_ID", 983 | "CLIENT_SECRET", 984 | host="www.bretagne.duchy", 985 | ) 986 | 987 | client.auth_url("https://www.bretagne.duchy/callback") 988 | 989 | assert mock_api_requests.calls.last is not None 990 | request, _ = mock_api_requests.calls.last 991 | url = str(request.url) 992 | assert url.startswith("http://localhost:8000") 993 | assert request.headers["Host"] == "www.bretagne.duchy" 994 | 995 | @pytest.mark.asyncio 996 | async def test_async_client(self, mock_api_requests: respx.MockRouter): 997 | client = FiefAsync( 998 | "http://localhost:8000", 999 | "CLIENT_ID", 1000 | "CLIENT_SECRET", 1001 | host="www.bretagne.duchy", 1002 | ) 1003 | 1004 | await client.auth_url("https://www.bretagne.duchy/callback") 1005 | 1006 | assert mock_api_requests.calls.last is not None 1007 | request, _ = mock_api_requests.calls.last 1008 | url = str(request.url) 1009 | assert url.startswith("http://localhost:8000") 1010 | assert request.headers["Host"] == "www.bretagne.duchy" 1011 | -------------------------------------------------------------------------------- /fief_client/client.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | import uuid 4 | from collections.abc import Mapping 5 | from enum import Enum 6 | from typing import Any, Optional, TypedDict, Union 7 | from urllib.parse import urlencode, urlsplit, urlunsplit 8 | 9 | import httpx 10 | from httpx._types import CertTypes, VerifyTypes 11 | from jwcrypto import jwk, jwt 12 | 13 | from fief_client.crypto import is_valid_hash 14 | 15 | HTTPXClient = Union[httpx.Client, httpx.AsyncClient] 16 | 17 | 18 | class FiefACR(str, Enum): 19 | """ 20 | List of defined Authentication Context Class Reference. 21 | """ 22 | 23 | LEVEL_ZERO = "0" 24 | """Level 0. No authentication was performed, a previous session was used.""" 25 | LEVEL_ONE = "1" 26 | """Level 1. Password authentication was performed.""" 27 | 28 | def __lt__(self, other: object) -> bool: 29 | return self._compare(other, True, True) 30 | 31 | def __le__(self, other: object) -> bool: 32 | return self._compare(other, False, True) 33 | 34 | def __gt__(self, other: object) -> bool: 35 | return self._compare(other, True, False) 36 | 37 | def __ge__(self, other: object) -> bool: 38 | return self._compare(other, False, False) 39 | 40 | def _compare(self, other: object, strict: bool, asc: bool) -> bool: 41 | if not isinstance(other, FiefACR): 42 | return NotImplemented # pragma: no cover 43 | 44 | if self == other: 45 | return not strict 46 | 47 | for elem in FiefACR: 48 | if self == elem: 49 | return asc 50 | elif other == elem: 51 | return not asc 52 | raise RuntimeError() # pragma: no cover 53 | 54 | 55 | class FiefTokenResponse(TypedDict): 56 | """ 57 | Typed dictionary containing the tokens and related information returned by Fief after a successful authentication. 58 | """ 59 | 60 | access_token: str 61 | """Access token you can use to call the Fief API.""" 62 | id_token: str 63 | """ID token containing user information.""" 64 | token_type: str 65 | """Type of token, usually `bearer`.""" 66 | expires_in: int 67 | """Number of seconds after which the tokens will expire.""" 68 | refresh_token: Optional[str] 69 | """Token provided only if scope `offline_access` was granted. Allows you to retrieve fresh tokens using the `Fief.auth_refresh_token` method.""" 70 | 71 | 72 | class FiefAccessTokenInfo(TypedDict): 73 | """ 74 | Typed dictionary containing information about the access token. 75 | 76 | **Example:** 77 | 78 | ```json 79 | { 80 | "id": "aeeb8bfa-e8f4-4724-9427-c3d5af66190e", 81 | "scope": ["openid", "required_scope"], 82 | "acr": "1", 83 | "permissions": ["castles:read", "castles:create", "castles:update", "castles:delete"], 84 | "access_token": "ACCESS_TOKEN", 85 | } 86 | ``` 87 | """ 88 | 89 | id: uuid.UUID 90 | """ID of the user.""" 91 | scope: list[str] 92 | """List of granted scopes for this access token.""" 93 | acr: FiefACR 94 | """Level of Authentication Context class Reference.""" 95 | permissions: list[str] 96 | """List of [granted permissions](https://docs.fief.dev/getting-started/access-control/) for this user.""" 97 | access_token: str 98 | """Access token you can use to call the Fief API.""" 99 | 100 | 101 | class FiefUserInfo(TypedDict): 102 | """ 103 | Dictionary containing user information. 104 | 105 | **Example:** 106 | 107 | ```json 108 | { 109 | "sub": "aeeb8bfa-e8f4-4724-9427-c3d5af66190e", 110 | "email": "anne@bretagne.duchy", 111 | "tenant_id": "c91ecb7f-359c-4244-8385-51ecd6c0d06b", 112 | "fields": { 113 | "first_name": "Anne", 114 | "last_name": "De Bretagne" 115 | } 116 | } 117 | ``` 118 | """ 119 | 120 | sub: str 121 | """ 122 | ID of the user. 123 | """ 124 | email: str 125 | """ 126 | Email address of the user. 127 | """ 128 | tenant_id: str 129 | """ 130 | ID of the [tenant](https://docs.fief.dev/getting-started/tenants/) associated to the user. 131 | """ 132 | fields: dict[str, Any] 133 | """ 134 | [User fields](https://docs.fief.dev/getting-started/user-fields/) values for this user, indexed by their slug. 135 | """ 136 | 137 | 138 | class FiefError(Exception): 139 | """Base Fief client error.""" 140 | 141 | 142 | class FiefRequestError(FiefError): 143 | """The request to Fief server resulted in an error.""" 144 | 145 | def __init__(self, status_code: int, detail: str) -> None: 146 | self.status_code = status_code 147 | self.detail = detail 148 | self.message = f"[{status_code}] - {detail}" 149 | super().__init__(self.message) 150 | 151 | 152 | class FiefAccessTokenInvalid(FiefError): 153 | """The access token is invalid.""" 154 | 155 | 156 | class FiefAccessTokenExpired(FiefError): 157 | """The access token is expired.""" 158 | 159 | 160 | class FiefAccessTokenMissingScope(FiefError): 161 | """The access token is missing a required scope.""" 162 | 163 | 164 | class FiefAccessTokenACRTooLow(FiefError): 165 | """The access token doesn't meet the minimum ACR level.""" 166 | 167 | 168 | class FiefAccessTokenMissingPermission(FiefError): 169 | """The access token is missing a required permission.""" 170 | 171 | 172 | class FiefIdTokenInvalid(FiefError): 173 | """The ID token is invalid.""" 174 | 175 | 176 | class BaseFief: 177 | """ 178 | Base Fief authentication client. 179 | """ 180 | 181 | base_url: str 182 | """Base URL of your Fief tenant.""" 183 | client_id: str 184 | """ID of your Fief client.""" 185 | client_secret: Optional[str] = None 186 | """ 187 | Secret of your Fief client. 188 | 189 | If you're implementing a desktop app, it's not recommended to use it, 190 | since it can be easily found by the end-user in the source code. 191 | The recommended way is to use a [Public client](https://docs.fief.dev/getting-started/clients/#public-clients). 192 | """ 193 | encryption_key: Optional[jwk.JWK] = None 194 | """""" 195 | 196 | _openid_configuration: Optional[dict[str, Any]] = None 197 | _jwks: Optional[jwk.JWKSet] = None 198 | 199 | _verify: VerifyTypes 200 | _cert: CertTypes 201 | 202 | def __init__( 203 | self, 204 | base_url: str, 205 | client_id: str, 206 | client_secret: Optional[str] = None, 207 | *, 208 | encryption_key: Optional[str] = None, 209 | host: Optional[str] = None, 210 | verify: VerifyTypes = True, 211 | cert: Optional[CertTypes] = None, 212 | ) -> None: 213 | """ 214 | Initialize the client. 215 | 216 | :param base_url: Base URL of your Fief tenant. 217 | :param client_id: ID of your Fief client. 218 | :param client_secret: Secret of your Fief client. 219 | If you're implementing a desktop app, it's not recommended to use it, 220 | since it can be easily found by the end-user in the source code. 221 | The recommended way is to use a [Public client](https://docs.fief.dev/getting-started/clients/#public-clients). 222 | :param encryption_key: Encryption key of your Fief client. 223 | Necessary only if [ID Token encryption](https://docs.fief.dev/going-further/id-token-encryption/) is enabled. 224 | :param verify: Corresponds to the [verify parameter of HTTPX](https://www.python-httpx.org/advanced/#changing-the-verification-defaults). 225 | Useful to customize SSL connection handling. 226 | :param cert: Corresponds to the [cert parameter of HTTPX](https://www.python-httpx.org/advanced/#client-side-certificates). 227 | Useful to customize SSL connection handling. 228 | """ 229 | self.base_url = base_url 230 | self.client_id = client_id 231 | self.client_secret = client_secret 232 | if encryption_key is not None: 233 | self.encryption_key = jwk.JWK.from_json(encryption_key) 234 | self.host = host 235 | self.verify = verify 236 | self.cert = cert 237 | 238 | def _get_endpoint_url( 239 | self, 240 | openid_configuration: dict[str, Any], 241 | field: str, 242 | *, 243 | absolute: bool = False, 244 | ) -> str: 245 | """ 246 | Return the specified endpoint from OpenID configuration. 247 | 248 | If `absolute` is `False`, we only retain the path, 249 | as the host might is not always relevant in our context. 250 | 251 | Typically, we might be in a Docker environment where the client app has to make 252 | request to the Fief server through Docker networking. Therefore, we do not 253 | want the client to use the absolute URL generated by OpenID Configuration, but 254 | rather stick to the host specified on the client configuration. 255 | """ 256 | if not absolute: 257 | (scheme, netloc, *components) = urlsplit(self.base_url) 258 | host = self.host if self.host is not None else netloc 259 | host_base_url = urlunsplit((scheme, host, *components)) 260 | return openid_configuration[field].split(host_base_url)[1] 261 | return openid_configuration[field] 262 | 263 | def _auth_url( 264 | self, 265 | openid_configuration: dict[str, Any], 266 | redirect_uri: str, 267 | *, 268 | state: Optional[str] = None, 269 | scope: Optional[list[str]] = None, 270 | code_challenge: Optional[str] = None, 271 | code_challenge_method: Optional[str] = None, 272 | lang: Optional[str] = None, 273 | extras_params: Optional[Mapping[str, str]] = None, 274 | ) -> str: 275 | params = { 276 | "response_type": "code", 277 | "client_id": self.client_id, 278 | "redirect_uri": redirect_uri, 279 | } 280 | 281 | if state is not None: 282 | params["state"] = state 283 | 284 | if scope is not None: 285 | params["scope"] = " ".join(scope) 286 | 287 | if code_challenge is not None and code_challenge_method is not None: 288 | params["code_challenge"] = code_challenge 289 | params["code_challenge_method"] = code_challenge_method 290 | 291 | if lang is not None: 292 | params["lang"] = lang 293 | 294 | if extras_params is not None: 295 | params = {**params, **extras_params} 296 | 297 | authorization_endpoint = self._get_endpoint_url( 298 | openid_configuration, "authorization_endpoint", absolute=True 299 | ) 300 | return f"{authorization_endpoint}?{urlencode(params)}" 301 | 302 | def _validate_access_token( 303 | self, 304 | access_token: str, 305 | jwks: jwk.JWKSet, 306 | *, 307 | required_scope: Optional[list[str]] = None, 308 | required_acr: Optional[FiefACR] = None, 309 | required_permissions: Optional[list[str]] = None, 310 | ) -> FiefAccessTokenInfo: 311 | try: 312 | decoded_token = jwt.JWT(jwt=access_token, algs=["RS256"], key=jwks) 313 | claims = json.loads(decoded_token.claims) 314 | access_token_scope = claims["scope"].split() 315 | if required_scope is not None: 316 | for scope in required_scope: 317 | if scope not in access_token_scope: 318 | raise FiefAccessTokenMissingScope() 319 | 320 | try: 321 | acr = FiefACR(claims["acr"]) 322 | except ValueError as e: 323 | raise FiefAccessTokenInvalid() from e 324 | 325 | if required_acr is not None: 326 | if acr < required_acr: 327 | raise FiefAccessTokenACRTooLow() 328 | 329 | permissions: list[str] = claims["permissions"] 330 | if required_permissions is not None: 331 | for required_permission in required_permissions: 332 | if required_permission not in permissions: 333 | raise FiefAccessTokenMissingPermission() 334 | 335 | return { 336 | "id": uuid.UUID(claims["sub"]), 337 | "scope": access_token_scope, 338 | "acr": acr, 339 | "permissions": permissions, 340 | "access_token": access_token, 341 | } 342 | 343 | except jwt.JWTExpired as e: 344 | raise FiefAccessTokenExpired() from e 345 | except (jwt.JWException, KeyError, ValueError) as e: 346 | raise FiefAccessTokenInvalid() from e 347 | 348 | def _decode_id_token( 349 | self, 350 | id_token: str, 351 | jwks: jwk.JWKSet, 352 | *, 353 | code: Optional[str] = None, 354 | access_token: Optional[str] = None, 355 | ) -> FiefUserInfo: 356 | try: 357 | if self.encryption_key is not None: 358 | decrypted_id_token = jwt.JWT(jwt=id_token, key=self.encryption_key) 359 | id_token_claims = decrypted_id_token.claims 360 | else: 361 | id_token_claims = id_token 362 | 363 | signed_id_token = jwt.JWT(jwt=id_token_claims, algs=["RS256"], key=jwks) 364 | claims = json.loads(signed_id_token.claims) 365 | 366 | if "c_hash" in claims: 367 | if code is None or not is_valid_hash(code, claims["c_hash"]): 368 | raise FiefIdTokenInvalid() 369 | 370 | if "at_hash" in claims: 371 | if access_token is None or not is_valid_hash( 372 | access_token, claims["at_hash"] 373 | ): 374 | raise FiefIdTokenInvalid() 375 | 376 | except (jwt.JWException, TypeError) as e: 377 | raise FiefIdTokenInvalid() from e 378 | else: 379 | return claims 380 | 381 | def _get_openid_configuration_request(self, client: HTTPXClient) -> httpx.Request: 382 | return client.build_request("GET", "/.well-known/openid-configuration") 383 | 384 | def _get_auth_exchange_token_request( 385 | self, 386 | client: HTTPXClient, 387 | *, 388 | endpoint: str, 389 | code: str, 390 | redirect_uri: str, 391 | code_verifier: Optional[str] = None, 392 | ) -> httpx.Request: 393 | data = { 394 | "client_id": self.client_id, 395 | "grant_type": "authorization_code", 396 | "code": code, 397 | "redirect_uri": redirect_uri, 398 | } 399 | if code_verifier is not None: 400 | data["code_verifier"] = code_verifier 401 | if self.client_secret is not None: 402 | data["client_secret"] = self.client_secret 403 | return client.build_request("POST", endpoint, data=data) 404 | 405 | def _get_auth_refresh_token_request( 406 | self, 407 | client: HTTPXClient, 408 | *, 409 | endpoint: str, 410 | refresh_token: str, 411 | scope: Optional[list[str]] = None, 412 | ) -> httpx.Request: 413 | data = { 414 | "client_id": self.client_id, 415 | "grant_type": "refresh_token", 416 | "refresh_token": refresh_token, 417 | } 418 | if self.client_secret is not None: 419 | data["client_secret"] = self.client_secret 420 | if scope is not None: 421 | data["scope"] = " ".join(scope) 422 | 423 | return client.build_request("POST", endpoint, data=data) 424 | 425 | def _get_userinfo_request( 426 | self, client: HTTPXClient, *, endpoint: str, access_token: str 427 | ) -> httpx.Request: 428 | return client.build_request( 429 | "GET", endpoint, headers={"Authorization": f"Bearer {access_token}"} 430 | ) 431 | 432 | def _get_update_profile_request( 433 | self, 434 | client: HTTPXClient, 435 | *, 436 | endpoint: str, 437 | access_token: str, 438 | data: dict[str, Any], 439 | ) -> httpx.Request: 440 | return client.build_request( 441 | "PATCH", 442 | endpoint, 443 | headers={"Authorization": f"Bearer {access_token}"}, 444 | json=data, 445 | ) 446 | 447 | def _get_change_password_request( 448 | self, 449 | client: HTTPXClient, 450 | *, 451 | endpoint: str, 452 | access_token: str, 453 | new_password: str, 454 | ) -> httpx.Request: 455 | return client.build_request( 456 | "PATCH", 457 | endpoint, 458 | headers={"Authorization": f"Bearer {access_token}"}, 459 | json={"password": new_password}, 460 | ) 461 | 462 | def _get_email_change_request( 463 | self, 464 | client: HTTPXClient, 465 | *, 466 | endpoint: str, 467 | access_token: str, 468 | email: str, 469 | ) -> httpx.Request: 470 | return client.build_request( 471 | "PATCH", 472 | endpoint, 473 | headers={"Authorization": f"Bearer {access_token}"}, 474 | json={"email": email}, 475 | ) 476 | 477 | def _get_email_verify_request( 478 | self, 479 | client: HTTPXClient, 480 | *, 481 | endpoint: str, 482 | access_token: str, 483 | code: str, 484 | ) -> httpx.Request: 485 | return client.build_request( 486 | "POST", 487 | endpoint, 488 | headers={"Authorization": f"Bearer {access_token}"}, 489 | json={"code": code}, 490 | ) 491 | 492 | def _handle_request_error(self, response: httpx.Response): 493 | if response.is_error: 494 | raise FiefRequestError(response.status_code, response.text) 495 | 496 | 497 | class Fief(BaseFief): 498 | """Sync Fief authentication client.""" 499 | 500 | def __init__( 501 | self, 502 | base_url: str, 503 | client_id: str, 504 | client_secret: Optional[str] = None, 505 | *, 506 | encryption_key: Optional[str] = None, 507 | host: Optional[str] = None, 508 | verify: VerifyTypes = True, 509 | cert: Optional[CertTypes] = None, 510 | ) -> None: 511 | super().__init__( 512 | base_url, 513 | client_id, 514 | client_secret, 515 | encryption_key=encryption_key, 516 | host=host, 517 | verify=verify, 518 | cert=cert, 519 | ) 520 | 521 | def auth_url( 522 | self, 523 | redirect_uri: str, 524 | *, 525 | state: Optional[str] = None, 526 | scope: Optional[list[str]] = None, 527 | code_challenge: Optional[str] = None, 528 | code_challenge_method: Optional[str] = None, 529 | lang: Optional[str] = None, 530 | extras_params: Optional[Mapping[str, str]] = None, 531 | ) -> str: 532 | """ 533 | Return an authorization URL. 534 | 535 | :param redirect_uri: Your callback URI where the user will be redirected after Fief authentication. 536 | :param state: Optional string that will be returned back in the callback parameters to allow you to retrieve state information. 537 | :param scope: Optional list of scopes to ask for. 538 | :param code_challenge: Optional code challenge for 539 | [PKCE process](https://docs.fief.dev/going-further/pkce/). 540 | :param code_challenge_method: Method used to hash the PKCE code challenge. 541 | :param lang: Optional parameter to set the user locale. 542 | Should be a valid [RFC 3066](https://www.rfc-editor.org/rfc/rfc3066) language identifier, like `fr` or `pt-PT`. 543 | If not provided, the user locale is determined by their browser settings. 544 | :param extras_params: Optional dictionary containing [specific parameters](https://docs.fief.dev/going-further/authorize-url/). 545 | 546 | **Example:** 547 | 548 | ```py 549 | auth_url = fief.auth_url("http://localhost:8000/callback", scope=["openid"]) 550 | ``` 551 | """ 552 | openid_configuration = self._get_openid_configuration() 553 | return self._auth_url( 554 | openid_configuration, 555 | redirect_uri, 556 | state=state, 557 | scope=scope, 558 | code_challenge=code_challenge, 559 | code_challenge_method=code_challenge_method, 560 | lang=lang, 561 | extras_params=extras_params, 562 | ) 563 | 564 | def auth_callback( 565 | self, code: str, redirect_uri: str, *, code_verifier: Optional[str] = None 566 | ) -> tuple[FiefTokenResponse, FiefUserInfo]: 567 | """ 568 | Return a `FiefTokenResponse` and `FiefUserInfo` in exchange of an authorization code. 569 | 570 | :param code: The authorization code. 571 | :param redirect_uri: The exact same `redirect_uri` you passed to the authorization URL. 572 | :param code_verifier: The raw 573 | [PKCE](https://docs.fief.dev/going-further/pkce/) code used to generate the code challenge during authorization. 574 | 575 | **Example:** 576 | 577 | ```py 578 | tokens, userinfo = fief.auth_callback("CODE", "http://localhost:8000/callback") 579 | ``` 580 | """ 581 | token_response = self._auth_exchange_token( 582 | code, redirect_uri, code_verifier=code_verifier 583 | ) 584 | jwks = self._get_jwks() 585 | userinfo = self._decode_id_token( 586 | token_response["id_token"], 587 | jwks, 588 | code=code, 589 | access_token=token_response.get("access_token"), 590 | ) 591 | return token_response, userinfo 592 | 593 | def auth_refresh_token( 594 | self, refresh_token: str, *, scope: Optional[list[str]] = None 595 | ) -> tuple[FiefTokenResponse, FiefUserInfo]: 596 | """ 597 | Return fresh `FiefTokenResponse` and `FiefUserInfo` in exchange of a refresh token 598 | 599 | :param refresh_token: A valid refresh token. 600 | :param scope: Optional list of scopes to ask for. 601 | If not provided, the access token will share the same list of scopes as requested the first time. 602 | Otherwise, it should be a subset of the original list of scopes. 603 | 604 | **Example:** 605 | 606 | ```py 607 | tokens, userinfo = fief.auth_refresh_token("REFRESH_TOKEN") 608 | ``` 609 | """ 610 | token_endpoint = self._get_endpoint_url( 611 | self._get_openid_configuration(), "token_endpoint" 612 | ) 613 | with self._get_httpx_client() as client: 614 | request = self._get_auth_refresh_token_request( 615 | client, 616 | endpoint=token_endpoint, 617 | refresh_token=refresh_token, 618 | scope=scope, 619 | ) 620 | response = client.send(request) 621 | 622 | self._handle_request_error(response) 623 | 624 | token_response = response.json() 625 | jwks = self._get_jwks() 626 | userinfo = self._decode_id_token( 627 | token_response["id_token"], 628 | jwks, 629 | access_token=token_response.get("access_token"), 630 | ) 631 | return token_response, userinfo 632 | 633 | def validate_access_token( 634 | self, 635 | access_token: str, 636 | *, 637 | required_scope: Optional[list[str]] = None, 638 | required_acr: Optional[FiefACR] = None, 639 | required_permissions: Optional[list[str]] = None, 640 | ) -> FiefAccessTokenInfo: 641 | """ 642 | Check if an access token is valid and optionally that it has a required list of scopes, 643 | or a required list of [permissions](https://docs.fief.dev/getting-started/access-control/). 644 | Returns a `FiefAccessTokenInfo`. 645 | 646 | :param access_token: The access token to validate. 647 | :param required_scope: Optional list of scopes to check for. 648 | :param required_acr: Optional minimum ACR level required. 649 | Read more: https://docs.fief.dev/going-further/acr/ 650 | :param required_permissions: Optional list of permissions to check for. 651 | 652 | **Example: Validate access token with required scopes** 653 | 654 | ```py 655 | try: 656 | access_token_info = fief.validate_access_token("ACCESS_TOKEN", required_scope=["required_scope"]) 657 | except FiefAccessTokenInvalid: 658 | print("Invalid access token") 659 | except FiefAccessTokenExpired: 660 | print("Expired access token") 661 | except FiefAccessTokenMissingScope: 662 | print("Missing required scope") 663 | 664 | print(access_token_info) 665 | ``` 666 | 667 | **Example: Validate access token with minimum ACR level** 668 | 669 | ```py 670 | try: 671 | access_token_info = fief.validate_access_token("ACCESS_TOKEN", required_acr=FiefACR.LEVEL_ONE) 672 | except FiefAccessTokenInvalid: 673 | print("Invalid access token") 674 | except FiefAccessTokenExpired: 675 | print("Expired access token") 676 | except FiefAccessTokenACRTooLow: 677 | print("ACR too low") 678 | 679 | print(access_token_info) 680 | ``` 681 | 682 | **Example: Validate access token with required permissions** 683 | 684 | ```py 685 | try: 686 | access_token_info = fief.validate_access_token("ACCESS_TOKEN", required_permissions=["castles:create", "castles:read"]) 687 | except FiefAccessTokenInvalid: 688 | print("Invalid access token") 689 | except FiefAccessTokenExpired: 690 | print("Expired access token") 691 | except FiefAccessTokenMissingPermission: 692 | print("Missing required permission") 693 | 694 | print(access_token_info) 695 | ``` 696 | """ 697 | jwks = self._get_jwks() 698 | return self._validate_access_token( 699 | access_token, 700 | jwks, 701 | required_scope=required_scope, 702 | required_acr=required_acr, 703 | required_permissions=required_permissions, 704 | ) 705 | 706 | def userinfo(self, access_token: str) -> FiefUserInfo: 707 | """ 708 | Return fresh `FiefUserInfo` from the Fief API using a valid access token. 709 | 710 | :param access_token: A valid access token. 711 | 712 | **Example:** 713 | 714 | ```py 715 | userinfo = fief.userinfo("ACCESS_TOKEN") 716 | ``` 717 | """ 718 | userinfo_endpoint = self._get_endpoint_url( 719 | self._get_openid_configuration(), "userinfo_endpoint" 720 | ) 721 | with self._get_httpx_client() as client: 722 | request = self._get_userinfo_request( 723 | client, endpoint=userinfo_endpoint, access_token=access_token 724 | ) 725 | response = client.send(request) 726 | 727 | self._handle_request_error(response) 728 | 729 | return response.json() 730 | 731 | def update_profile(self, access_token: str, data: dict[str, Any]) -> FiefUserInfo: 732 | """ 733 | Update user information with the Fief API using a valid access token. 734 | 735 | :param access_token: A valid access token. 736 | :param data: A dictionary containing the data to update. 737 | 738 | **Example: Update user field** 739 | 740 | To update [user field](https://docs.fief.dev/getting-started/user-fields/) values, you need to nest them into a `fields` dictionary, indexed by their slug. 741 | 742 | ```py 743 | userinfo = fief.update_profile("ACCESS_TOKEN", { "fields": { "first_name": "Anne" } }) 744 | ``` 745 | """ 746 | update_profile_endpoint = f"{self.base_url}/api/profile" 747 | 748 | with self._get_httpx_client() as client: 749 | request = self._get_update_profile_request( 750 | client, 751 | endpoint=update_profile_endpoint, 752 | access_token=access_token, 753 | data=data, 754 | ) 755 | response = client.send(request) 756 | 757 | self._handle_request_error(response) 758 | 759 | return response.json() 760 | 761 | def change_password(self, access_token: str, new_password: str) -> FiefUserInfo: 762 | """ 763 | Change the user password with the Fief API using a valid access token. 764 | 765 | **An access token with an ACR of at least level 1 is required.** 766 | 767 | :param access_token: A valid access token. 768 | :param new_password: The new password. 769 | 770 | **Example** 771 | 772 | ```py 773 | userinfo = fief.change_password("ACCESS_TOKEN", "herminetincture") 774 | ``` 775 | """ 776 | change_password_profile_endpoint = f"{self.base_url}/api/password" 777 | 778 | with self._get_httpx_client() as client: 779 | request = self._get_change_password_request( 780 | client, 781 | endpoint=change_password_profile_endpoint, 782 | access_token=access_token, 783 | new_password=new_password, 784 | ) 785 | response = client.send(request) 786 | 787 | self._handle_request_error(response) 788 | 789 | return response.json() 790 | 791 | def email_change(self, access_token: str, email: str) -> FiefUserInfo: 792 | """ 793 | Request an email change with the Fief API using a valid access token. 794 | 795 | The user will receive a verification code on this new email address. 796 | It shall be used with the method `email_verify` to complete the modification. 797 | 798 | **An access token with an ACR of at least level 1 is required.** 799 | 800 | :param access_token: A valid access token. 801 | :param email: The new email address. 802 | 803 | **Example** 804 | 805 | ```py 806 | userinfo = fief.email_change("ACCESS_TOKEN", "anne@nantes.city") 807 | ``` 808 | """ 809 | email_change_endpoint = f"{self.base_url}/api/email/change" 810 | 811 | with self._get_httpx_client() as client: 812 | request = self._get_email_change_request( 813 | client, 814 | endpoint=email_change_endpoint, 815 | access_token=access_token, 816 | email=email, 817 | ) 818 | response = client.send(request) 819 | 820 | self._handle_request_error(response) 821 | 822 | return response.json() 823 | 824 | def email_verify(self, access_token: str, code: str) -> FiefUserInfo: 825 | """ 826 | Verify the user email with the Fief API using a valid access token and verification code. 827 | 828 | **An access token with an ACR of at least level 1 is required.** 829 | 830 | :param access_token: A valid access token. 831 | :param code: The verification code received by email. 832 | 833 | **Example** 834 | 835 | ```py 836 | userinfo = fief.email_verify("ACCESS_TOKEN", "ABCDE") 837 | ``` 838 | """ 839 | email_verify_endpoint = f"{self.base_url}/api/email/verify" 840 | 841 | with self._get_httpx_client() as client: 842 | request = self._get_email_verify_request( 843 | client, 844 | endpoint=email_verify_endpoint, 845 | access_token=access_token, 846 | code=code, 847 | ) 848 | response = client.send(request) 849 | 850 | self._handle_request_error(response) 851 | 852 | return response.json() 853 | 854 | def logout_url(self, redirect_uri: str) -> str: 855 | """ 856 | Returns a logout URL. If you redirect the user to this page, Fief will clear the session stored on its side. 857 | 858 | **You're still responsible for clearing your own session mechanism if any.** 859 | 860 | :param redirect_uri: A valid URL where the user will be redirected after the logout process. 861 | 862 | **Example:** 863 | 864 | ```py 865 | logout_url = fief.logout_url("http://localhost:8000") 866 | ``` 867 | """ 868 | params = {"redirect_uri": redirect_uri} 869 | return f"{self.base_url}/logout?{urlencode(params)}" 870 | 871 | @contextlib.contextmanager 872 | def _get_httpx_client(self): 873 | headers = {} 874 | if self.host is not None: 875 | headers["Host"] = self.host 876 | 877 | with httpx.Client( 878 | base_url=self.base_url, headers=headers, verify=self.verify, cert=self.cert 879 | ) as client: 880 | yield client 881 | 882 | def _get_openid_configuration(self) -> dict[str, Any]: 883 | if self._openid_configuration is not None: 884 | return self._openid_configuration 885 | 886 | with self._get_httpx_client() as client: 887 | request = self._get_openid_configuration_request(client) 888 | response = client.send(request) 889 | json = response.json() 890 | self._openid_configuration = json 891 | return json 892 | 893 | def _get_jwks(self) -> jwk.JWKSet: 894 | if self._jwks is not None: 895 | return self._jwks 896 | 897 | jwks_uri = self._get_endpoint_url(self._get_openid_configuration(), "jwks_uri") 898 | with self._get_httpx_client() as client: 899 | response = client.get(jwks_uri) 900 | self._jwks = jwk.JWKSet.from_json(response.text) 901 | return self._jwks 902 | 903 | def _auth_exchange_token( 904 | self, code: str, redirect_uri: str, *, code_verifier: Optional[str] = None 905 | ) -> FiefTokenResponse: 906 | token_endpoint = self._get_endpoint_url( 907 | self._get_openid_configuration(), "token_endpoint" 908 | ) 909 | with self._get_httpx_client() as client: 910 | request = self._get_auth_exchange_token_request( 911 | client, 912 | endpoint=token_endpoint, 913 | code=code, 914 | redirect_uri=redirect_uri, 915 | code_verifier=code_verifier, 916 | ) 917 | response = client.send(request) 918 | 919 | self._handle_request_error(response) 920 | 921 | return response.json() 922 | 923 | 924 | class FiefAsync(BaseFief): 925 | """Async Fief authentication client.""" 926 | 927 | def __init__( 928 | self, 929 | base_url: str, 930 | client_id: str, 931 | client_secret: Optional[str] = None, 932 | *, 933 | encryption_key: Optional[str] = None, 934 | host: Optional[str] = None, 935 | verify: VerifyTypes = True, 936 | cert: Optional[CertTypes] = None, 937 | ) -> None: 938 | super().__init__( 939 | base_url, 940 | client_id, 941 | client_secret, 942 | encryption_key=encryption_key, 943 | host=host, 944 | verify=verify, 945 | cert=cert, 946 | ) 947 | 948 | async def auth_url( 949 | self, 950 | redirect_uri: str, 951 | *, 952 | state: Optional[str] = None, 953 | scope: Optional[list[str]] = None, 954 | code_challenge: Optional[str] = None, 955 | code_challenge_method: Optional[str] = None, 956 | lang: Optional[str] = None, 957 | extras_params: Optional[Mapping[str, str]] = None, 958 | ) -> str: 959 | """ 960 | Return an authorization URL. 961 | 962 | :param redirect_uri: Your callback URI where the user will be redirected after Fief authentication. 963 | :param state: Optional string that will be returned back in the callback parameters to allow you to retrieve state information. 964 | :param scope: Optional list of scopes to ask for. 965 | :param code_challenge: Optional code challenge for 966 | [PKCE process](https://docs.fief.dev/going-further/pkce/). 967 | :param code_challenge_method: Method used to hash the PKCE code challenge. 968 | :param lang: Optional parameter to set the user locale. 969 | Should be a valid [RFC 3066](https://www.rfc-editor.org/rfc/rfc3066) language identifier, like `fr` or `pt-PT`. 970 | If not provided, the user locale is determined by their browser settings. 971 | :param extras_params: Optional dictionary containing [specific parameters](https://docs.fief.dev/going-further/authorize-url/). 972 | 973 | **Example:** 974 | 975 | ```py 976 | auth_url = await fief.auth_url("http://localhost:8000/callback", scope=["openid"]) 977 | ``` 978 | """ 979 | openid_configuration = await self._get_openid_configuration() 980 | return self._auth_url( 981 | openid_configuration, 982 | redirect_uri, 983 | state=state, 984 | scope=scope, 985 | code_challenge=code_challenge, 986 | code_challenge_method=code_challenge_method, 987 | lang=lang, 988 | extras_params=extras_params, 989 | ) 990 | 991 | async def auth_callback( 992 | self, code: str, redirect_uri: str, *, code_verifier: Optional[str] = None 993 | ) -> tuple[FiefTokenResponse, FiefUserInfo]: 994 | """ 995 | Return a `FiefTokenResponse` and `FiefUserInfo` in exchange of an authorization code. 996 | 997 | :param code: The authorization code. 998 | :param redirect_uri: The exact same `redirect_uri` you passed to the authorization URL. 999 | :param code_verifier: The raw 1000 | [PKCE](https://docs.fief.dev/going-further/pkce/) code used to generate the code challenge during authorization. 1001 | 1002 | **Example:** 1003 | 1004 | ```py 1005 | tokens, userinfo = await fief.auth_callback("CODE", "http://localhost:8000/callback") 1006 | ``` 1007 | """ 1008 | token_response = await self._auth_exchange_token( 1009 | code, redirect_uri, code_verifier=code_verifier 1010 | ) 1011 | jwks = await self._get_jwks() 1012 | userinfo = self._decode_id_token( 1013 | token_response["id_token"], 1014 | jwks, 1015 | code=code, 1016 | access_token=token_response.get("access_token"), 1017 | ) 1018 | return token_response, userinfo 1019 | 1020 | async def auth_refresh_token( 1021 | self, refresh_token: str, *, scope: Optional[list[str]] = None 1022 | ) -> tuple[FiefTokenResponse, FiefUserInfo]: 1023 | """ 1024 | Return fresh `FiefTokenResponse` and `FiefUserInfo` in exchange of a refresh token 1025 | 1026 | :param refresh_token: A valid refresh token. 1027 | :param scope: Optional list of scopes to ask for. 1028 | If not provided, the access token will share the same list of scopes as requested the first time. 1029 | Otherwise, it should be a subset of the original list of scopes. 1030 | 1031 | **Example:** 1032 | 1033 | ```py 1034 | tokens, userinfo = await fief.auth_refresh_token("REFRESH_TOKEN") 1035 | ``` 1036 | """ 1037 | token_endpoint = self._get_endpoint_url( 1038 | await self._get_openid_configuration(), "token_endpoint" 1039 | ) 1040 | async with self._get_httpx_client() as client: 1041 | request = self._get_auth_refresh_token_request( 1042 | client, 1043 | endpoint=token_endpoint, 1044 | refresh_token=refresh_token, 1045 | scope=scope, 1046 | ) 1047 | response = await client.send(request) 1048 | 1049 | self._handle_request_error(response) 1050 | 1051 | token_response = response.json() 1052 | 1053 | jwks = await self._get_jwks() 1054 | userinfo = self._decode_id_token( 1055 | token_response["id_token"], 1056 | jwks, 1057 | access_token=token_response.get("access_token"), 1058 | ) 1059 | return token_response, userinfo 1060 | 1061 | async def validate_access_token( 1062 | self, 1063 | access_token: str, 1064 | *, 1065 | required_scope: Optional[list[str]] = None, 1066 | required_acr: Optional[FiefACR] = None, 1067 | required_permissions: Optional[list[str]] = None, 1068 | ) -> FiefAccessTokenInfo: 1069 | """ 1070 | Check if an access token is valid and optionally that it has a required list of scopes, 1071 | or a required list of [permissions](https://docs.fief.dev/getting-started/access-control/). 1072 | Returns a `FiefAccessTokenInfo`. 1073 | 1074 | :param access_token: The access token to validate. 1075 | :param required_scope: Optional list of scopes to check for. 1076 | :param required_acr: Optional minimum ACR level required. 1077 | Read more: https://docs.fief.dev/going-further/acr/ 1078 | :param required_permissions: Optional list of permissions to check for. 1079 | 1080 | **Example: Validate access token with required scopes** 1081 | 1082 | ```py 1083 | try: 1084 | access_token_info = await fief.validate_access_token("ACCESS_TOKEN", required_scope=["required_scope"]) 1085 | except FiefAccessTokenInvalid: 1086 | print("Invalid access token") 1087 | except FiefAccessTokenExpired: 1088 | print("Expired access token") 1089 | except FiefAccessTokenMissingScope: 1090 | print("Missing required scope") 1091 | 1092 | print(access_token_info) 1093 | ``` 1094 | 1095 | **Example: Validate access token with minimum ACR level** 1096 | 1097 | ```py 1098 | try: 1099 | access_token_info = await fief.validate_access_token("ACCESS_TOKEN", required_acr=FiefACR.LEVEL_ONE) 1100 | except FiefAccessTokenInvalid: 1101 | print("Invalid access token") 1102 | except FiefAccessTokenExpired: 1103 | print("Expired access token") 1104 | except FiefAccessTokenACRTooLow: 1105 | print("ACR too low") 1106 | 1107 | print(access_token_info) 1108 | ``` 1109 | 1110 | **Example: Validate access token with required permissions** 1111 | 1112 | ```py 1113 | try: 1114 | access_token_info = await fief.validate_access_token("ACCESS_TOKEN", required_permissions=["castles:create", "castles:read"]) 1115 | except FiefAccessTokenInvalid: 1116 | print("Invalid access token") 1117 | except FiefAccessTokenExpired: 1118 | print("Expired access token") 1119 | except FiefAccessTokenMissingPermission: 1120 | print("Missing required permission") 1121 | 1122 | print(access_token_info) 1123 | ``` 1124 | """ 1125 | jwks = await self._get_jwks() 1126 | return self._validate_access_token( 1127 | access_token, 1128 | jwks, 1129 | required_scope=required_scope, 1130 | required_acr=required_acr, 1131 | required_permissions=required_permissions, 1132 | ) 1133 | 1134 | async def userinfo(self, access_token: str) -> FiefUserInfo: 1135 | """ 1136 | Return fresh `FiefUserInfo` from the Fief API using a valid access token. 1137 | 1138 | :param access_token: A valid access token. 1139 | 1140 | **Example:** 1141 | 1142 | ```py 1143 | userinfo = await fief.userinfo("ACCESS_TOKEN") 1144 | ``` 1145 | """ 1146 | userinfo_endpoint = self._get_endpoint_url( 1147 | await self._get_openid_configuration(), "userinfo_endpoint" 1148 | ) 1149 | async with self._get_httpx_client() as client: 1150 | request = self._get_userinfo_request( 1151 | client, endpoint=userinfo_endpoint, access_token=access_token 1152 | ) 1153 | response = await client.send(request) 1154 | 1155 | self._handle_request_error(response) 1156 | 1157 | return response.json() 1158 | 1159 | async def update_profile( 1160 | self, access_token: str, data: dict[str, Any] 1161 | ) -> FiefUserInfo: 1162 | """ 1163 | Update user information with the Fief API using a valid access token. 1164 | 1165 | :param access_token: A valid access token. 1166 | :param data: A dictionary containing the data to update. 1167 | 1168 | **Example: Update user field** 1169 | 1170 | To update [user field](https://docs.fief.dev/getting-started/user-fields/) values, you need to nest them into a `fields` dictionary, indexed by their slug. 1171 | 1172 | ```py 1173 | userinfo = await fief.update_profile("ACCESS_TOKEN", { "fields": { "first_name": "Anne" } }) 1174 | ``` 1175 | """ 1176 | update_profile_endpoint = f"{self.base_url}/api/profile" 1177 | 1178 | async with self._get_httpx_client() as client: 1179 | request = self._get_update_profile_request( 1180 | client, 1181 | endpoint=update_profile_endpoint, 1182 | access_token=access_token, 1183 | data=data, 1184 | ) 1185 | response = await client.send(request) 1186 | 1187 | self._handle_request_error(response) 1188 | 1189 | return response.json() 1190 | 1191 | async def change_password( 1192 | self, access_token: str, new_password: str 1193 | ) -> FiefUserInfo: 1194 | """ 1195 | Change the user password with the Fief API using a valid access token. 1196 | 1197 | **An access token with an ACR of at least level 1 is required.** 1198 | 1199 | :param access_token: A valid access token. 1200 | :param new_password: The new password. 1201 | 1202 | **Example** 1203 | 1204 | ```py 1205 | userinfo = await fief.change_password("ACCESS_TOKEN", "herminetincture") 1206 | ``` 1207 | """ 1208 | change_password_profile_endpoint = f"{self.base_url}/api/password" 1209 | 1210 | async with self._get_httpx_client() as client: 1211 | request = self._get_change_password_request( 1212 | client, 1213 | endpoint=change_password_profile_endpoint, 1214 | access_token=access_token, 1215 | new_password=new_password, 1216 | ) 1217 | response = await client.send(request) 1218 | 1219 | self._handle_request_error(response) 1220 | 1221 | return response.json() 1222 | 1223 | async def email_change(self, access_token: str, email: str) -> FiefUserInfo: 1224 | """ 1225 | Request an email change with the Fief API using a valid access token. 1226 | 1227 | The user will receive a verification code on this new email address. 1228 | It shall be used with the method `email_verify` to complete the modification. 1229 | 1230 | **An access token with an ACR of at least level 1 is required.** 1231 | 1232 | :param access_token: A valid access token. 1233 | :param email: The new email address. 1234 | 1235 | **Example** 1236 | 1237 | ```py 1238 | userinfo = await fief.email_change("ACCESS_TOKEN", "anne@nantes.city") 1239 | ``` 1240 | """ 1241 | email_change_endpoint = f"{self.base_url}/api/email/change" 1242 | 1243 | async with self._get_httpx_client() as client: 1244 | request = self._get_email_change_request( 1245 | client, 1246 | endpoint=email_change_endpoint, 1247 | access_token=access_token, 1248 | email=email, 1249 | ) 1250 | response = await client.send(request) 1251 | 1252 | self._handle_request_error(response) 1253 | 1254 | return response.json() 1255 | 1256 | async def email_verify(self, access_token: str, code: str) -> FiefUserInfo: 1257 | """ 1258 | Verify the user email with the Fief API using a valid access token and verification code. 1259 | 1260 | **An access token with an ACR of at least level 1 is required.** 1261 | 1262 | :param access_token: A valid access token. 1263 | :param code: The verification code received by email. 1264 | 1265 | **Example** 1266 | 1267 | ```py 1268 | userinfo = fief.email_verify("ACCESS_TOKEN", "ABCDE") 1269 | ``` 1270 | """ 1271 | email_verify_endpoint = f"{self.base_url}/api/email/verify" 1272 | 1273 | async with self._get_httpx_client() as client: 1274 | request = self._get_email_verify_request( 1275 | client, 1276 | endpoint=email_verify_endpoint, 1277 | access_token=access_token, 1278 | code=code, 1279 | ) 1280 | response = await client.send(request) 1281 | 1282 | self._handle_request_error(response) 1283 | 1284 | return response.json() 1285 | 1286 | async def logout_url(self, redirect_uri: str) -> str: 1287 | """ 1288 | Returns a logout URL. If you redirect the user to this page, Fief will clear the session stored on its side. 1289 | 1290 | **You're still responsible for clearing your own session mechanism if any.** 1291 | 1292 | :param redirect_uri: A valid URL where the user will be redirected after the logout process: 1293 | 1294 | **Example:** 1295 | 1296 | ```py 1297 | logout_url = await fief.logout_url("http://localhost:8000") 1298 | ``` 1299 | """ 1300 | params = {"redirect_uri": redirect_uri} 1301 | return f"{self.base_url}/logout?{urlencode(params)}" 1302 | 1303 | @contextlib.asynccontextmanager 1304 | async def _get_httpx_client(self): 1305 | headers = {} 1306 | if self.host is not None: 1307 | headers["Host"] = self.host 1308 | 1309 | async with httpx.AsyncClient( 1310 | base_url=self.base_url, headers=headers, verify=self.verify, cert=self.cert 1311 | ) as client: 1312 | yield client 1313 | 1314 | async def _get_openid_configuration(self) -> dict[str, Any]: 1315 | if self._openid_configuration is not None: 1316 | return self._openid_configuration 1317 | 1318 | async with self._get_httpx_client() as client: 1319 | request = self._get_openid_configuration_request(client) 1320 | response = await client.send(request) 1321 | json = response.json() 1322 | self._openid_configuration = json 1323 | return json 1324 | 1325 | async def _get_jwks(self) -> jwk.JWKSet: 1326 | if self._jwks is not None: 1327 | return self._jwks 1328 | 1329 | jwks_uri = self._get_endpoint_url( 1330 | await self._get_openid_configuration(), "jwks_uri" 1331 | ) 1332 | async with self._get_httpx_client() as client: 1333 | response = await client.get(jwks_uri) 1334 | self._jwks = jwk.JWKSet.from_json(response.text) 1335 | return self._jwks 1336 | 1337 | async def _auth_exchange_token( 1338 | self, code: str, redirect_uri: str, *, code_verifier: Optional[str] = None 1339 | ) -> FiefTokenResponse: 1340 | token_endpoint = self._get_endpoint_url( 1341 | await self._get_openid_configuration(), "token_endpoint" 1342 | ) 1343 | async with self._get_httpx_client() as client: 1344 | request = self._get_auth_exchange_token_request( 1345 | client, 1346 | endpoint=token_endpoint, 1347 | code=code, 1348 | redirect_uri=redirect_uri, 1349 | code_verifier=code_verifier, 1350 | ) 1351 | response = await client.send(request) 1352 | 1353 | self._handle_request_error(response) 1354 | 1355 | return response.json() 1356 | --------------------------------------------------------------------------------