├── 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 | [](https://github.com/fief-dev/fief-python/actions)
4 | [](https://codecov.io/gh/fief-dev/fief-python)
5 | [](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 |
--------------------------------------------------------------------------------