fauth 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fauth-0.2.2/.github/actions/setup-python-env/action.yml +34 -0
- fauth-0.2.2/.github/workflows/cicd.yml +136 -0
- {fauth-0.2.1 → fauth-0.2.2}/.github/workflows/pre-commit-autoupdate.yml +8 -16
- {fauth-0.2.1 → fauth-0.2.2}/.pre-commit-config.yaml +1 -1
- {fauth-0.2.1 → fauth-0.2.2}/CHANGELOG.md +50 -0
- {fauth-0.2.1 → fauth-0.2.2}/PKG-INFO +161 -39
- {fauth-0.2.1 → fauth-0.2.2}/README.md +160 -38
- {fauth-0.2.1 → fauth-0.2.2}/pyproject.toml +1 -1
- {fauth-0.2.1 → fauth-0.2.2}/uv.lock +46 -46
- fauth-0.2.1/.github/workflows/cicd.yml +0 -181
- fauth-0.2.1/requirements.txt +0 -54
- {fauth-0.2.1 → fauth-0.2.2}/.github/dependabot.yml +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/.gitignore +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/LICENSE +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/api/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/api/router.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/core/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/core/config.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/core/exceptions.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/core/schemas.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/crypto/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/crypto/jwt.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/crypto/password.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/providers/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/providers/protocols.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/providers/provider.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/config.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/fakes.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/provider.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/transports/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/transports/base.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/transports/bearer.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/utils/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/fauth/utils/logging.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/pytest.ini +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/api/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/api/conftest.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/api/test_openapi.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/api/test_router.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/conftest.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/core/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/core/conftest.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/core/test_config.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/core/test_exceptions.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/conftest.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/test_jwt.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/test_password.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/providers/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/providers/conftest.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/providers/test_provider.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/testing/__init__.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/testing/test_testing.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/utils/conftest.py +0 -0
- {fauth-0.2.1 → fauth-0.2.2}/tests/utils/test_logging.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: 'Setup Python Environment'
|
|
2
|
+
description: 'Install Python, uv, and project dependencies'
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
python-version:
|
|
6
|
+
description: 'Python version to set up'
|
|
7
|
+
required: true
|
|
8
|
+
uv-version:
|
|
9
|
+
description: 'uv version to install'
|
|
10
|
+
required: false
|
|
11
|
+
default: '0.9.11'
|
|
12
|
+
install-dependencies:
|
|
13
|
+
description: 'Whether to run uv sync after setup'
|
|
14
|
+
required: false
|
|
15
|
+
default: 'true'
|
|
16
|
+
|
|
17
|
+
runs:
|
|
18
|
+
using: 'composite'
|
|
19
|
+
steps:
|
|
20
|
+
- name: Set up Python ${{ inputs.python-version }}
|
|
21
|
+
uses: actions/setup-python@v6
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ inputs.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@v7
|
|
27
|
+
with:
|
|
28
|
+
version: ${{ inputs.uv-version }}
|
|
29
|
+
python-version: ${{ inputs.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
if: inputs.install-dependencies == 'true'
|
|
33
|
+
shell: bash
|
|
34
|
+
run: uv sync --all-groups --frozen
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["main"]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
id-token: write
|
|
11
|
+
contents: write
|
|
12
|
+
pull-requests: write
|
|
13
|
+
actions: write
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
lint:
|
|
17
|
+
name: Lint (Python ${{ matrix.python-version }})
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
strategy:
|
|
20
|
+
matrix:
|
|
21
|
+
python-version: ["3.12", "3.13", "3.14"]
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v6
|
|
24
|
+
with:
|
|
25
|
+
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
|
26
|
+
fetch-depth: 0
|
|
27
|
+
|
|
28
|
+
- name: Setup environment
|
|
29
|
+
uses: ./.github/actions/setup-python-env
|
|
30
|
+
with:
|
|
31
|
+
python-version: ${{ matrix.python-version }}
|
|
32
|
+
|
|
33
|
+
- name: Run lint and format checks
|
|
34
|
+
run: uv run poe format
|
|
35
|
+
|
|
36
|
+
test:
|
|
37
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
strategy:
|
|
40
|
+
matrix:
|
|
41
|
+
python-version: ["3.12", "3.13", "3.14"]
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v6
|
|
44
|
+
with:
|
|
45
|
+
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
|
46
|
+
fetch-depth: 0
|
|
47
|
+
|
|
48
|
+
- name: Setup environment
|
|
49
|
+
uses: ./.github/actions/setup-python-env
|
|
50
|
+
with:
|
|
51
|
+
python-version: ${{ matrix.python-version }}
|
|
52
|
+
|
|
53
|
+
- name: Run unit tests
|
|
54
|
+
env:
|
|
55
|
+
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
|
56
|
+
run: uv run poe test
|
|
57
|
+
|
|
58
|
+
update-hooks:
|
|
59
|
+
name: Update pre-commit hooks
|
|
60
|
+
if: github.event_name == 'pull_request'
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@v6
|
|
64
|
+
with:
|
|
65
|
+
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
|
66
|
+
ref: ${{ github.head_ref }}
|
|
67
|
+
fetch-depth: 0
|
|
68
|
+
|
|
69
|
+
- name: Setup environment
|
|
70
|
+
uses: ./.github/actions/setup-python-env
|
|
71
|
+
with:
|
|
72
|
+
python-version: "3.13"
|
|
73
|
+
|
|
74
|
+
- name: Autoupdate pre-commit hooks
|
|
75
|
+
run: uv run pre-commit autoupdate
|
|
76
|
+
|
|
77
|
+
- name: Commit updated hooks
|
|
78
|
+
run: |
|
|
79
|
+
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
|
80
|
+
git config --local user.name "github-actions[bot]"
|
|
81
|
+
git add .pre-commit-config.yaml
|
|
82
|
+
git diff --staged --quiet || git commit -m "chore(config): update pre-commit hooks"
|
|
83
|
+
git push origin HEAD:${{ github.head_ref }}
|
|
84
|
+
|
|
85
|
+
versioning:
|
|
86
|
+
name: Versioning
|
|
87
|
+
runs-on: ubuntu-latest
|
|
88
|
+
if: github.ref == 'refs/heads/main'
|
|
89
|
+
needs: [lint, test]
|
|
90
|
+
steps:
|
|
91
|
+
- uses: actions/checkout@v6
|
|
92
|
+
with:
|
|
93
|
+
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
|
94
|
+
ref: main
|
|
95
|
+
fetch-depth: 0
|
|
96
|
+
|
|
97
|
+
- name: Setup environment
|
|
98
|
+
uses: ./.github/actions/setup-python-env
|
|
99
|
+
with:
|
|
100
|
+
python-version: "3.13"
|
|
101
|
+
|
|
102
|
+
- name: Bump version and update changelog
|
|
103
|
+
id: release
|
|
104
|
+
uses: python-semantic-release/python-semantic-release@v9.21.1
|
|
105
|
+
with:
|
|
106
|
+
github_token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
|
107
|
+
|
|
108
|
+
outputs:
|
|
109
|
+
released: ${{ steps.release.outputs.released }}
|
|
110
|
+
|
|
111
|
+
publish:
|
|
112
|
+
name: Publish to PyPI
|
|
113
|
+
runs-on: ubuntu-latest
|
|
114
|
+
if: needs.versioning.outputs.released == 'true'
|
|
115
|
+
needs: [versioning]
|
|
116
|
+
environment:
|
|
117
|
+
name: pypi
|
|
118
|
+
url: https://pypi.org/p/fauth
|
|
119
|
+
steps:
|
|
120
|
+
- uses: actions/checkout@v6
|
|
121
|
+
with:
|
|
122
|
+
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
|
123
|
+
ref: main
|
|
124
|
+
fetch-depth: 0
|
|
125
|
+
|
|
126
|
+
- name: Setup environment
|
|
127
|
+
uses: ./.github/actions/setup-python-env
|
|
128
|
+
with:
|
|
129
|
+
python-version: "3.13"
|
|
130
|
+
install-dependencies: "false"
|
|
131
|
+
|
|
132
|
+
- name: Build package
|
|
133
|
+
run: uv build
|
|
134
|
+
|
|
135
|
+
- name: Publish to PyPI
|
|
136
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -5,8 +5,8 @@ name: Pre-commit Update
|
|
|
5
5
|
|
|
6
6
|
on:
|
|
7
7
|
schedule:
|
|
8
|
-
- cron: '0 2 * *
|
|
9
|
-
workflow_dispatch:
|
|
8
|
+
- cron: '0 2 * * 1' # Mondays at 2 AM UTC
|
|
9
|
+
workflow_dispatch:
|
|
10
10
|
|
|
11
11
|
permissions:
|
|
12
12
|
contents: read
|
|
@@ -20,34 +20,26 @@ jobs:
|
|
|
20
20
|
runs-on: ubuntu-latest
|
|
21
21
|
steps:
|
|
22
22
|
- uses: actions/checkout@v6
|
|
23
|
-
- uses: actions/setup-python@v6
|
|
24
23
|
with:
|
|
25
|
-
|
|
24
|
+
token: ${{ secrets.GH_TOKEN }}
|
|
25
|
+
fetch-depth: 0
|
|
26
26
|
|
|
27
|
-
- name:
|
|
28
|
-
uses:
|
|
27
|
+
- name: Setup environment
|
|
28
|
+
uses: ./.github/actions/setup-python-env
|
|
29
29
|
with:
|
|
30
|
-
version: '0.9.11'
|
|
31
30
|
python-version: '3.13'
|
|
32
31
|
|
|
33
|
-
- name: Install dependencies
|
|
34
|
-
run: |
|
|
35
|
-
uv sync --all-groups --frozen
|
|
36
|
-
|
|
37
32
|
- name: Autoupdate pre-commit hooks
|
|
38
|
-
working-directory: '.'
|
|
39
33
|
run: uv run pre-commit autoupdate
|
|
40
34
|
|
|
41
35
|
- name: Create Pull Request
|
|
42
36
|
uses: peter-evans/create-pull-request@v8
|
|
43
|
-
env:
|
|
44
|
-
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
45
37
|
with:
|
|
46
|
-
token: ${{
|
|
38
|
+
token: ${{ secrets.GH_TOKEN }}
|
|
47
39
|
branch: chore/pre-commit-autoupdate
|
|
48
40
|
title: Update pre-commit hooks
|
|
49
41
|
commit-message: 'chore(config): update pre-commit hooks'
|
|
50
42
|
body: |
|
|
51
43
|
This PR was created automatically by the pre-commit autoupdate workflow.
|
|
52
44
|
It updates the pre-commit hooks to their latest versions.
|
|
53
|
-
labels: update
|
|
45
|
+
labels: update-hooks
|
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.2.2 (2026-04-04)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- Add badge for dependabot
|
|
9
|
+
([`22f4719`](https://github.com/justmatias/fauth/commit/22f47195a489fbd9a63f5772c1081b887a2b0ea1))
|
|
10
|
+
|
|
11
|
+
### Chores
|
|
12
|
+
|
|
13
|
+
- Add python version matrix to CI/CD workflow
|
|
14
|
+
([`9576512`](https://github.com/justmatias/fauth/commit/9576512733ed709c9bce62a97101d397a010ace8))
|
|
15
|
+
|
|
16
|
+
- Fix cicd for dependabot prs
|
|
17
|
+
([`befb729`](https://github.com/justmatias/fauth/commit/befb729fca3e37ba23c5158b29659f712455f6ac))
|
|
18
|
+
|
|
19
|
+
- Implement setup env action and remove legacy requirements.txt
|
|
20
|
+
([`781a552`](https://github.com/justmatias/fauth/commit/781a552e29c3d658247762c5cf13bb66042976fb))
|
|
21
|
+
|
|
22
|
+
- Remove extract curly brace
|
|
23
|
+
([`2661dd3`](https://github.com/justmatias/fauth/commit/2661dd3f94da79909fa900d6d6dc341e199f5bb4))
|
|
24
|
+
|
|
25
|
+
- Restore checkout step in jobs
|
|
26
|
+
([`ad4e33d`](https://github.com/justmatias/fauth/commit/ad4e33d1b2575f368b7cb256c1bf9d34b52855dd))
|
|
27
|
+
|
|
28
|
+
- Set python version to min supported
|
|
29
|
+
([`8c981a4`](https://github.com/justmatias/fauth/commit/8c981a40939df8230ba1f776cf1dc6f57090ebad))
|
|
30
|
+
|
|
31
|
+
- **config**: Update pre-commit hooks
|
|
32
|
+
([`ab09f61`](https://github.com/justmatias/fauth/commit/ab09f6185058cece84ddf5ef371b268b7fb75429))
|
|
33
|
+
|
|
34
|
+
- **config**: Update pre-commit hooks
|
|
35
|
+
([`6ec7a6d`](https://github.com/justmatias/fauth/commit/6ec7a6de9c618d1d5bf8b0f157d52888346f49a1))
|
|
36
|
+
|
|
37
|
+
- **config**: Update requirements.txt
|
|
38
|
+
([`862978f`](https://github.com/justmatias/fauth/commit/862978f8073c065086b3080dafc45e3b604b98ce))
|
|
39
|
+
|
|
40
|
+
- **deps**: Bump cryptography from 46.0.5 to 46.0.6
|
|
41
|
+
([`a61129d`](https://github.com/justmatias/fauth/commit/a61129df6777736ecff5df4c1a878a70b378025d))
|
|
42
|
+
|
|
43
|
+
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.6. -
|
|
44
|
+
[Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) -
|
|
45
|
+
[Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)
|
|
46
|
+
|
|
47
|
+
--- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.6
|
|
48
|
+
|
|
49
|
+
dependency-type: indirect ...
|
|
50
|
+
|
|
51
|
+
Signed-off-by: dependabot[bot] <support@github.com>
|
|
52
|
+
|
|
53
|
+
|
|
4
54
|
## v0.2.1 (2026-04-03)
|
|
5
55
|
|
|
6
56
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fauth
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Ergonomic, lightweight JWT authentication for FastAPI. Secure your routes instantly with plug-and-play dependency injection
|
|
5
5
|
Project-URL: Homepage, https://github.com/justmatias/fauth
|
|
6
6
|
Project-URL: Repository, https://github.com/justmatias/fauth
|
|
@@ -37,6 +37,7 @@ An ergonomic, plug-and-play authentication library for FastAPI.
|
|
|
37
37
|
|
|
38
38
|
`fauth` eliminates boilerplate around JWT, password hashing, user fetching, and Role-Based Access Control (RBAC) by leveraging FastAPI's Dependency Injection (`Depends`), Pydantic models, and Python Protocols.
|
|
39
39
|
|
|
40
|
+
[](https://github.com/justmatias/fauth/actions/workflows/dependabot/dependabot-updates)
|
|
40
41
|
[](https://pypi.org/project/fauth/)
|
|
41
42
|
[](https://pypi.org/project/fauth/)
|
|
42
43
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -78,26 +79,37 @@ from pydantic import BaseModel
|
|
|
78
79
|
class User(BaseModel):
|
|
79
80
|
id: str
|
|
80
81
|
username: str
|
|
82
|
+
hashed_password: str
|
|
81
83
|
is_active: bool = True
|
|
82
84
|
roles: list[str] = []
|
|
83
85
|
permissions: list[str] = []
|
|
84
86
|
```
|
|
85
87
|
|
|
86
|
-
### 2. Implement the `UserLoader`
|
|
88
|
+
### 2. Implement the `UserLoader` and `IdentityLoader` protocols
|
|
87
89
|
|
|
88
|
-
FAuth uses a callback-based approach
|
|
90
|
+
FAuth uses a callback-based approach. You provide functions that retrieve users from your data source:
|
|
89
91
|
|
|
90
92
|
```python
|
|
91
|
-
from fauth import TokenPayload
|
|
93
|
+
from fauth import TokenPayload, hash_password
|
|
92
94
|
|
|
93
95
|
# Your database, ORM, or any data source
|
|
94
96
|
DB: dict[str, User] = {
|
|
95
|
-
"user-123": User(
|
|
97
|
+
"user-123": User(
|
|
98
|
+
id="user-123",
|
|
99
|
+
username="alice",
|
|
100
|
+
hashed_password=hash_password("s3cret"),
|
|
101
|
+
roles=["admin"],
|
|
102
|
+
permissions=["read", "write"],
|
|
103
|
+
),
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
# UserLoader — resolves a user from a decoded JWT
|
|
98
107
|
async def load_user(payload: TokenPayload) -> User | None:
|
|
99
|
-
"""Look up a user by the `sub` claim from the JWT."""
|
|
100
108
|
return DB.get(payload.sub)
|
|
109
|
+
|
|
110
|
+
# IdentityLoader — resolves a user by identifier (for password authentication)
|
|
111
|
+
async def load_identity(identifier: str) -> User | None:
|
|
112
|
+
return next((u for u in DB.values() if u.username == identifier), None)
|
|
101
113
|
```
|
|
102
114
|
|
|
103
115
|
### 3. Create the AuthProvider
|
|
@@ -106,7 +118,11 @@ async def load_user(payload: TokenPayload) -> User | None:
|
|
|
106
118
|
from fauth import AuthConfig, AuthProvider
|
|
107
119
|
|
|
108
120
|
config = AuthConfig(secret_key="my-super-secret-key")
|
|
109
|
-
auth: AuthProvider[User] = AuthProvider(
|
|
121
|
+
auth: AuthProvider[User] = AuthProvider(
|
|
122
|
+
config=config,
|
|
123
|
+
user_loader=load_user,
|
|
124
|
+
identity_loader=load_identity,
|
|
125
|
+
)
|
|
110
126
|
```
|
|
111
127
|
|
|
112
128
|
### 4. Wire it into FastAPI
|
|
@@ -117,15 +133,16 @@ from fastapi import FastAPI, Depends
|
|
|
117
133
|
app = FastAPI()
|
|
118
134
|
|
|
119
135
|
@app.post("/login")
|
|
120
|
-
async def login():
|
|
121
|
-
|
|
136
|
+
async def login(username: str, password: str):
|
|
137
|
+
user = await auth.authenticate(username, password)
|
|
138
|
+
return await auth.login(sub=user.id)
|
|
122
139
|
|
|
123
140
|
@app.get("/me")
|
|
124
141
|
async def get_me(user: User = Depends(auth.require_user)):
|
|
125
142
|
return {"message": f"Hello {user.username}"}
|
|
126
143
|
```
|
|
127
144
|
|
|
128
|
-
That's it. The `/me` endpoint is
|
|
145
|
+
That's it. The `/login` endpoint verifies credentials via `authenticate()`, then issues tokens via `login()`. The `/me` endpoint is protected — requests without a valid `Bearer` token will receive a `401 Unauthorized` response.
|
|
129
146
|
|
|
130
147
|
---
|
|
131
148
|
|
|
@@ -135,7 +152,7 @@ That's it. The `/me` endpoint is now protected. Requests without a valid `Bearer
|
|
|
135
152
|
from fastapi import FastAPI, Depends, HTTPException
|
|
136
153
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
137
154
|
from pydantic import BaseModel
|
|
138
|
-
from fauth import AuthConfig, AuthProvider, TokenPayload, SecureAPIRouter
|
|
155
|
+
from fauth import AuthConfig, AuthProvider, TokenPayload, SecureAPIRouter, hash_password
|
|
139
156
|
|
|
140
157
|
app = FastAPI()
|
|
141
158
|
|
|
@@ -143,41 +160,50 @@ app = FastAPI()
|
|
|
143
160
|
class User(BaseModel):
|
|
144
161
|
id: str
|
|
145
162
|
username: str
|
|
163
|
+
hashed_password: str
|
|
146
164
|
is_active: bool = True
|
|
147
165
|
roles: list[str] = []
|
|
148
166
|
permissions: list[str] = []
|
|
149
167
|
|
|
150
|
-
# Mock
|
|
168
|
+
# Mock databases
|
|
151
169
|
DB: dict[str, User] = {
|
|
152
|
-
"user-123": User(
|
|
170
|
+
"user-123": User(
|
|
171
|
+
id="user-123",
|
|
172
|
+
username="alice",
|
|
173
|
+
hashed_password=hash_password("s3cret"),
|
|
174
|
+
roles=["admin"],
|
|
175
|
+
permissions=["read", "write"],
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Identity lookup by username (used by authenticate)
|
|
180
|
+
IDENTITY_DB: dict[str, User] = {
|
|
181
|
+
"alice": DB["user-123"],
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
# 2. Define the callback that retrieves a user from the decoded JWT
|
|
156
185
|
async def load_user(payload: TokenPayload) -> User | None:
|
|
157
186
|
return DB.get(payload.sub)
|
|
158
187
|
|
|
159
|
-
# 3. Define
|
|
160
|
-
async def
|
|
161
|
-
|
|
162
|
-
for user in DB.values():
|
|
163
|
-
if user.username == identifier:
|
|
164
|
-
return user
|
|
165
|
-
return None
|
|
188
|
+
# 3. Define the callback that retrieves a user by identifier (for password auth)
|
|
189
|
+
async def load_identity(identifier: str) -> User | None:
|
|
190
|
+
return IDENTITY_DB.get(identifier)
|
|
166
191
|
|
|
167
192
|
# 4. Instantiate the auth component
|
|
168
193
|
config = AuthConfig(secret_key="my-super-secret-key", algorithm="HS256")
|
|
169
194
|
auth: AuthProvider[User] = AuthProvider(
|
|
170
195
|
config=config,
|
|
171
196
|
user_loader=load_user,
|
|
172
|
-
identity_loader=
|
|
197
|
+
identity_loader=load_identity,
|
|
173
198
|
)
|
|
174
199
|
|
|
175
200
|
# --- Routes ---
|
|
176
201
|
|
|
177
202
|
@app.post("/login")
|
|
178
|
-
async def login():
|
|
179
|
-
#
|
|
180
|
-
|
|
203
|
+
async def login(username: str, password: str):
|
|
204
|
+
# 5. Verify credentials, then issue tokens
|
|
205
|
+
user = await auth.authenticate(username, password)
|
|
206
|
+
return await auth.login(sub=user.id)
|
|
181
207
|
|
|
182
208
|
@app.get("/me")
|
|
183
209
|
async def get_me(user: User = Depends(auth.require_user)):
|
|
@@ -256,21 +282,24 @@ The main orchestrator. Provides FastAPI dependencies for authentication and auth
|
|
|
256
282
|
AuthProvider(
|
|
257
283
|
config: AuthConfig,
|
|
258
284
|
user_loader: UserLoader[T],
|
|
259
|
-
|
|
285
|
+
identity_loader: IdentityLoader[T] | None = None, # Required for authenticate()
|
|
286
|
+
transport: Transport | None = None, # Defaults to BearerTransport()
|
|
260
287
|
token_payload_schema: type[TokenPayload] = TokenPayload,
|
|
288
|
+
password_field_name: str = "hashed_password", # Attribute on user model holding the hash
|
|
261
289
|
)
|
|
262
290
|
```
|
|
263
291
|
|
|
264
292
|
#### Methods
|
|
265
293
|
|
|
266
|
-
| Method
|
|
267
|
-
|
|
|
268
|
-
| `require_user`
|
|
269
|
-
| `require_active_user`
|
|
270
|
-
| `require_roles(roles)`
|
|
271
|
-
| `require_permissions(perms)`
|
|
272
|
-
| `
|
|
273
|
-
| `
|
|
294
|
+
| Method | Returns | Description |
|
|
295
|
+
| ----------------------------------- | --------------- | ------------------------------------------------------------------------ |
|
|
296
|
+
| `require_user` | `T` | FastAPI dependency — extracts and validates the token, loads the user |
|
|
297
|
+
| `require_active_user` | `T` | Like `require_user`, but also checks `user.is_active` |
|
|
298
|
+
| `require_roles(roles)` | `Callable` | Returns a dependency that demands the user has all specified roles |
|
|
299
|
+
| `require_permissions(perms)` | `Callable` | Returns a dependency that demands the user has all specified permissions |
|
|
300
|
+
| `authenticate(identifier, password)`| `T` | Verifies credentials via `IdentityLoader` + password check |
|
|
301
|
+
| `login(sub, scopes?, extra?)` | `TokenResponse` | Issues access + refresh tokens for a given subject |
|
|
302
|
+
| `get_security_scheme()` | `SecurityBase` | Returns the OpenAPI security scheme for docs |
|
|
274
303
|
|
|
275
304
|
### `UserLoader` Protocol
|
|
276
305
|
|
|
@@ -292,6 +321,24 @@ class MyUserLoader:
|
|
|
292
321
|
return await self.db.get_user(token_payload.sub)
|
|
293
322
|
```
|
|
294
323
|
|
|
324
|
+
### `IdentityLoader` Protocol
|
|
325
|
+
|
|
326
|
+
Used by `authenticate()` to look up a user by an identifier (username, email, etc.):
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
# As a plain function
|
|
330
|
+
async def load_identity(identifier: str) -> User | None:
|
|
331
|
+
return await db.get_user_by_username(identifier)
|
|
332
|
+
|
|
333
|
+
# Or as a callable class
|
|
334
|
+
class MyIdentityLoader:
|
|
335
|
+
def __init__(self, db: Database):
|
|
336
|
+
self.db = db
|
|
337
|
+
|
|
338
|
+
async def __call__(self, identifier: str) -> User | None:
|
|
339
|
+
return await self.db.get_user_by_username(identifier)
|
|
340
|
+
```
|
|
341
|
+
|
|
295
342
|
### `TokenPayload`
|
|
296
343
|
|
|
297
344
|
The decoded JWT structure. Accepts extra claims via `model_config = ConfigDict(extra="allow")`.
|
|
@@ -362,6 +409,78 @@ is_valid = verify_password("my-password", hashed) # True
|
|
|
362
409
|
|
|
363
410
|
---
|
|
364
411
|
|
|
412
|
+
## Authentication with Password Verification
|
|
413
|
+
|
|
414
|
+
FAuth provides a built-in `authenticate()` method on `AuthProvider` that handles credential verification using the `IdentityLoader` protocol and Argon2 password hashing.
|
|
415
|
+
|
|
416
|
+
### Basic setup
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
from pydantic import BaseModel
|
|
420
|
+
from fauth import AuthConfig, AuthProvider, hash_password
|
|
421
|
+
|
|
422
|
+
class User(BaseModel):
|
|
423
|
+
id: str
|
|
424
|
+
username: str
|
|
425
|
+
hashed_password: str
|
|
426
|
+
is_active: bool = True
|
|
427
|
+
|
|
428
|
+
# Identity loader retrieves user by username/email/etc.
|
|
429
|
+
async def load_identity(identifier: str) -> User | None:
|
|
430
|
+
return await db.get_user_by_username(identifier)
|
|
431
|
+
|
|
432
|
+
# Token-based user loader (used by require_user)
|
|
433
|
+
async def load_user(payload) -> User | None:
|
|
434
|
+
return await db.get_user_by_id(payload.sub)
|
|
435
|
+
|
|
436
|
+
auth = AuthProvider(
|
|
437
|
+
config=AuthConfig(secret_key="my-secret"),
|
|
438
|
+
user_loader=load_user,
|
|
439
|
+
identity_loader=load_identity,
|
|
440
|
+
)
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Using `authenticate()` in a login endpoint
|
|
444
|
+
|
|
445
|
+
```python
|
|
446
|
+
from fastapi import FastAPI
|
|
447
|
+
|
|
448
|
+
app = FastAPI()
|
|
449
|
+
|
|
450
|
+
@app.post("/login")
|
|
451
|
+
async def login(username: str, password: str):
|
|
452
|
+
# Verifies password and checks is_active
|
|
453
|
+
user = await auth.authenticate(username, password)
|
|
454
|
+
# Issue tokens
|
|
455
|
+
return await auth.login(sub=user.id)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`authenticate()` performs three checks in order:
|
|
459
|
+
1. **User exists** — looks up the user via `IdentityLoader`. Raises `401` if not found.
|
|
460
|
+
2. **Password is valid** — verifies the plain password against the hashed password stored on the user. Raises `401` if invalid.
|
|
461
|
+
3. **User is active** — checks `user.is_active` (if the attribute exists). Raises `401` if inactive.
|
|
462
|
+
|
|
463
|
+
### Custom password field
|
|
464
|
+
|
|
465
|
+
By default, `authenticate()` reads the hash from `user.hashed_password`. If your model stores it under a different attribute name, pass `password_field_name`:
|
|
466
|
+
|
|
467
|
+
```python
|
|
468
|
+
class User(BaseModel):
|
|
469
|
+
id: str
|
|
470
|
+
pw_hash: str # non-standard field name
|
|
471
|
+
|
|
472
|
+
auth = AuthProvider(
|
|
473
|
+
config=config,
|
|
474
|
+
user_loader=load_user,
|
|
475
|
+
identity_loader=load_identity,
|
|
476
|
+
password_field_name="pw_hash",
|
|
477
|
+
)
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
> **Note:** If `AuthProvider` is created without an `identity_loader`, calling `authenticate()` will raise a `RuntimeError`.
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
365
484
|
## Custom Token Payload
|
|
366
485
|
|
|
367
486
|
If you need custom claims in your tokens (e.g., `tenant_id`, `organization_id`), subclass `TokenPayload` and pass it to `AuthProvider`:
|
|
@@ -548,11 +667,12 @@ async def test_secure_me_endpoint():
|
|
|
548
667
|
|
|
549
668
|
### Testing utilities reference
|
|
550
669
|
|
|
551
|
-
| Import | Description
|
|
552
|
-
| ----------------------------------------------------- |
|
|
553
|
-
| `build_fake_auth_provider(users?, config_overrides?)` | Creates an `AuthProvider` backed by in-memory fakes
|
|
554
|
-
| `fake_auth_config(**overrides)` | Returns an `AuthConfig` with safe test defaults
|
|
555
|
-
| `FakeUserLoader[T]` | In-memory `UserLoader` — populate with `.add_user(id, user)`
|
|
670
|
+
| Import | Description |
|
|
671
|
+
| ----------------------------------------------------- | --------------------------------------------------------------------- |
|
|
672
|
+
| `build_fake_auth_provider(users?, config_overrides?)` | Creates an `AuthProvider` backed by in-memory fakes |
|
|
673
|
+
| `fake_auth_config(**overrides)` | Returns an `AuthConfig` with safe test defaults |
|
|
674
|
+
| `FakeUserLoader[T]` | In-memory `UserLoader` — populate with `.add_user(id, user)` |
|
|
675
|
+
| `FakeIdentityLoader[T]` | In-memory `IdentityLoader` — populate with `.add_user(id, user)` |
|
|
556
676
|
|
|
557
677
|
---
|
|
558
678
|
|
|
@@ -625,7 +745,9 @@ FAuth raises `HTTPException` with standard HTTP status codes:
|
|
|
625
745
|
| Expired token | `401` | `"Token expired"` |
|
|
626
746
|
| Invalid/malformed token | `401` | `"Invalid token"` |
|
|
627
747
|
| User not found | `401` | `"User does not exist"` |
|
|
628
|
-
|
|
|
748
|
+
| Invalid credentials | `401` | `"Invalid credentials"` (from `authenticate()`) |
|
|
749
|
+
| Inactive user (token) | `400` | `"Inactive user"` (from `require_active_user`) |
|
|
750
|
+
| Inactive user (login) | `401` | `"Inactive user"` (from `authenticate()`) |
|
|
629
751
|
| Missing role | `403` | `"Missing role: {role}"` |
|
|
630
752
|
| Missing permission | `403` | `"Insufficient permissions: requires {permission} permission"` |
|
|
631
753
|
|