fauth 0.2.0__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.
Files changed (58) hide show
  1. fauth-0.2.2/.github/actions/setup-python-env/action.yml +34 -0
  2. fauth-0.2.2/.github/workflows/cicd.yml +136 -0
  3. {fauth-0.2.0 → fauth-0.2.2}/.github/workflows/pre-commit-autoupdate.yml +8 -16
  4. {fauth-0.2.0 → fauth-0.2.2}/.pre-commit-config.yaml +1 -1
  5. {fauth-0.2.0 → fauth-0.2.2}/CHANGELOG.md +66 -0
  6. {fauth-0.2.0 → fauth-0.2.2}/PKG-INFO +172 -37
  7. {fauth-0.2.0 → fauth-0.2.2}/README.md +171 -36
  8. {fauth-0.2.0 → fauth-0.2.2}/fauth/providers/protocols.py +6 -0
  9. {fauth-0.2.0 → fauth-0.2.2}/fauth/providers/provider.py +43 -3
  10. {fauth-0.2.0 → fauth-0.2.2}/fauth/testing/__init__.py +2 -1
  11. {fauth-0.2.0 → fauth-0.2.2}/fauth/testing/fakes.py +11 -0
  12. {fauth-0.2.0 → fauth-0.2.2}/pyproject.toml +1 -1
  13. {fauth-0.2.0 → fauth-0.2.2}/tests/providers/conftest.py +35 -4
  14. {fauth-0.2.0 → fauth-0.2.2}/tests/providers/test_provider.py +83 -0
  15. {fauth-0.2.0 → fauth-0.2.2}/uv.lock +46 -46
  16. fauth-0.2.0/.github/workflows/cicd.yml +0 -181
  17. fauth-0.2.0/requirements.txt +0 -54
  18. {fauth-0.2.0 → fauth-0.2.2}/.github/dependabot.yml +0 -0
  19. {fauth-0.2.0 → fauth-0.2.2}/.gitignore +0 -0
  20. {fauth-0.2.0 → fauth-0.2.2}/LICENSE +0 -0
  21. {fauth-0.2.0 → fauth-0.2.2}/fauth/__init__.py +0 -0
  22. {fauth-0.2.0 → fauth-0.2.2}/fauth/api/__init__.py +0 -0
  23. {fauth-0.2.0 → fauth-0.2.2}/fauth/api/router.py +0 -0
  24. {fauth-0.2.0 → fauth-0.2.2}/fauth/core/__init__.py +0 -0
  25. {fauth-0.2.0 → fauth-0.2.2}/fauth/core/config.py +0 -0
  26. {fauth-0.2.0 → fauth-0.2.2}/fauth/core/exceptions.py +0 -0
  27. {fauth-0.2.0 → fauth-0.2.2}/fauth/core/schemas.py +0 -0
  28. {fauth-0.2.0 → fauth-0.2.2}/fauth/crypto/__init__.py +0 -0
  29. {fauth-0.2.0 → fauth-0.2.2}/fauth/crypto/jwt.py +0 -0
  30. {fauth-0.2.0 → fauth-0.2.2}/fauth/crypto/password.py +0 -0
  31. {fauth-0.2.0 → fauth-0.2.2}/fauth/providers/__init__.py +0 -0
  32. {fauth-0.2.0 → fauth-0.2.2}/fauth/testing/config.py +0 -0
  33. {fauth-0.2.0 → fauth-0.2.2}/fauth/testing/provider.py +0 -0
  34. {fauth-0.2.0 → fauth-0.2.2}/fauth/transports/__init__.py +0 -0
  35. {fauth-0.2.0 → fauth-0.2.2}/fauth/transports/base.py +0 -0
  36. {fauth-0.2.0 → fauth-0.2.2}/fauth/transports/bearer.py +0 -0
  37. {fauth-0.2.0 → fauth-0.2.2}/fauth/utils/__init__.py +0 -0
  38. {fauth-0.2.0 → fauth-0.2.2}/fauth/utils/logging.py +0 -0
  39. {fauth-0.2.0 → fauth-0.2.2}/pytest.ini +0 -0
  40. {fauth-0.2.0 → fauth-0.2.2}/tests/__init__.py +0 -0
  41. {fauth-0.2.0 → fauth-0.2.2}/tests/api/__init__.py +0 -0
  42. {fauth-0.2.0 → fauth-0.2.2}/tests/api/conftest.py +0 -0
  43. {fauth-0.2.0 → fauth-0.2.2}/tests/api/test_openapi.py +0 -0
  44. {fauth-0.2.0 → fauth-0.2.2}/tests/api/test_router.py +0 -0
  45. {fauth-0.2.0 → fauth-0.2.2}/tests/conftest.py +0 -0
  46. {fauth-0.2.0 → fauth-0.2.2}/tests/core/__init__.py +0 -0
  47. {fauth-0.2.0 → fauth-0.2.2}/tests/core/conftest.py +0 -0
  48. {fauth-0.2.0 → fauth-0.2.2}/tests/core/test_config.py +0 -0
  49. {fauth-0.2.0 → fauth-0.2.2}/tests/core/test_exceptions.py +0 -0
  50. {fauth-0.2.0 → fauth-0.2.2}/tests/crypto/__init__.py +0 -0
  51. {fauth-0.2.0 → fauth-0.2.2}/tests/crypto/conftest.py +0 -0
  52. {fauth-0.2.0 → fauth-0.2.2}/tests/crypto/test_jwt.py +0 -0
  53. {fauth-0.2.0 → fauth-0.2.2}/tests/crypto/test_password.py +0 -0
  54. {fauth-0.2.0 → fauth-0.2.2}/tests/providers/__init__.py +0 -0
  55. {fauth-0.2.0 → fauth-0.2.2}/tests/testing/__init__.py +0 -0
  56. {fauth-0.2.0 → fauth-0.2.2}/tests/testing/test_testing.py +0 -0
  57. {fauth-0.2.0 → fauth-0.2.2}/tests/utils/conftest.py +0 -0
  58. {fauth-0.2.0 → 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: # Allows manual triggering of the workflow
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
- python-version: '3.13'
24
+ token: ${{ secrets.GH_TOKEN }}
25
+ fetch-depth: 0
26
26
 
27
- - name: Install uv
28
- uses: astral-sh/setup-uv@v7
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: ${{ env.GH_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
@@ -17,7 +17,7 @@ repos:
17
17
  - id: gitleaks
18
18
 
19
19
  - repo: https://github.com/astral-sh/ruff-pre-commit
20
- rev: 'v0.15.8'
20
+ rev: 'v0.15.9'
21
21
  hooks:
22
22
  - id: ruff-check
23
23
  name: ruff
@@ -1,6 +1,69 @@
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
+
54
+ ## v0.2.1 (2026-04-03)
55
+
56
+ ### Bug Fixes
57
+
58
+ - Implement AuthProvider for FastAPI authentication and add testing utilities
59
+ ([`f19f7ad`](https://github.com/justmatias/fauth/commit/f19f7ad32a8a79368d50862ffa3f4446dcc2cc70))
60
+
61
+ ### Chores
62
+
63
+ - **config**: Merge with main
64
+ ([`d8caae2`](https://github.com/justmatias/fauth/commit/d8caae2b3e357fcc2b3bc5f78c2929c1d0b12427))
65
+
66
+
4
67
  ## v0.2.0 (2026-04-02)
5
68
 
6
69
  ### Bug Fixes
@@ -32,6 +95,9 @@
32
95
 
33
96
  ### Bug Fixes
34
97
 
98
+ - **config**: Add authenticate method to AuthProvider with IdentityLoader support
99
+ ([`f905766`](https://github.com/justmatias/fauth/commit/f905766afa02394a28f97b319e52134576c4c614))
100
+
35
101
  - **config**: Add openapi security scheme support to AuthProvider and router dependencies
36
102
  ([`72d7754`](https://github.com/justmatias/fauth/commit/72d7754fb0519be979b6b00b5f253b245770698c))
37
103
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fauth
3
- Version: 0.2.0
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
+ [![Dependabot Updates](https://github.com/justmatias/fauth/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/justmatias/fauth/actions/workflows/dependabot/dependabot-updates)
40
41
  [![PyPI version](https://img.shields.io/pypi/v/fauth)](https://pypi.org/project/fauth/)
41
42
  [![Python versions](https://img.shields.io/pypi/pyversions/fauth)](https://pypi.org/project/fauth/)
42
43
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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` protocol
88
+ ### 2. Implement the `UserLoader` and `IdentityLoader` protocols
87
89
 
88
- FAuth uses a callback-based approach to load users. You provide a function that receives a decoded JWT payload and returns your user object:
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(id="user-123", username="alice", roles=["admin"], permissions=["read", "write"]),
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(config=config, user_loader=load_user)
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,24 +133,26 @@ from fastapi import FastAPI, Depends
117
133
  app = FastAPI()
118
134
 
119
135
  @app.post("/login")
120
- async def login():
121
- return await auth.login(sub="user-123")
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 now protected. Requests without a valid `Bearer` token will receive a `401 Unauthorized` response.
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
 
132
149
  ## Full Example
133
150
 
134
151
  ```python
135
- from fastapi import FastAPI, Depends
152
+ from fastapi import FastAPI, Depends, HTTPException
153
+ from fastapi.security import OAuth2PasswordRequestForm
136
154
  from pydantic import BaseModel
137
- from fauth import AuthConfig, AuthProvider, TokenPayload, SecureAPIRouter
155
+ from fauth import AuthConfig, AuthProvider, TokenPayload, SecureAPIRouter, hash_password
138
156
 
139
157
  app = FastAPI()
140
158
 
@@ -142,43 +160,64 @@ app = FastAPI()
142
160
  class User(BaseModel):
143
161
  id: str
144
162
  username: str
163
+ hashed_password: str
145
164
  is_active: bool = True
146
165
  roles: list[str] = []
147
166
  permissions: list[str] = []
148
167
 
149
- # Mock database
168
+ # Mock databases
150
169
  DB: dict[str, User] = {
151
- "user-123": User(id="user-123", username="alice", roles=["admin"], permissions=["read", "write"])
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"],
152
182
  }
153
183
 
154
184
  # 2. Define the callback that retrieves a user from the decoded JWT
155
185
  async def load_user(payload: TokenPayload) -> User | None:
156
186
  return DB.get(payload.sub)
157
187
 
158
- # 3. Instantiate the auth component
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)
191
+
192
+ # 4. Instantiate the auth component
159
193
  config = AuthConfig(secret_key="my-super-secret-key", algorithm="HS256")
160
- auth: AuthProvider[User] = AuthProvider(config=config, user_loader=load_user)
194
+ auth: AuthProvider[User] = AuthProvider(
195
+ config=config,
196
+ user_loader=load_user,
197
+ identity_loader=load_identity,
198
+ )
161
199
 
162
200
  # --- Routes ---
163
201
 
164
202
  @app.post("/login")
165
- async def login():
166
- # 4. Use `auth.login` to issue tokens (password verification omitted for now)
167
- return await auth.login(sub="user-123")
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)
168
207
 
169
208
  @app.get("/me")
170
209
  async def get_me(user: User = Depends(auth.require_user)):
171
- # 5. `auth.require_user` secures the endpoint automatically
210
+ # 6. `auth.require_user` secures the endpoint automatically
172
211
  return {"message": f"Hello {user.username}"}
173
212
 
174
213
  @app.get("/admin")
175
214
  async def get_admin_data(user: User = Depends(auth.require_roles(["admin"]))):
176
- # 6. `auth.require_roles` enforces RBAC with list of roles
215
+ # 7. `auth.require_roles` enforces RBAC with list of roles
177
216
  return {"secret_data": "Top secret admin info"}
178
217
 
179
218
  # --- Securing Multiple Routes ---
180
219
 
181
- # 7. Use `SecureAPIRouter` to protect an entire group of routes.
220
+ # 8. Use `SecureAPIRouter` to protect an entire group of routes.
182
221
  # Any route added to this router will require an active user automatically.
183
222
  # This also enables the "Authorize" button in Swagger UI!
184
223
  secure_router = SecureAPIRouter(auth_provider=auth, prefix="/internal", tags=["Protected"])
@@ -243,21 +282,24 @@ The main orchestrator. Provides FastAPI dependencies for authentication and auth
243
282
  AuthProvider(
244
283
  config: AuthConfig,
245
284
  user_loader: UserLoader[T],
246
- transport: Transport | None = None, # Defaults to BearerTransport()
285
+ identity_loader: IdentityLoader[T] | None = None, # Required for authenticate()
286
+ transport: Transport | None = None, # Defaults to BearerTransport()
247
287
  token_payload_schema: type[TokenPayload] = TokenPayload,
288
+ password_field_name: str = "hashed_password", # Attribute on user model holding the hash
248
289
  )
249
290
  ```
250
291
 
251
292
  #### Methods
252
293
 
253
- | Method | Returns | Description |
254
- | ----------------------------- | --------------- | ------------------------------------------------------------------------ |
255
- | `require_user` | `T` | FastAPI dependency — extracts and validates the token, loads the user |
256
- | `require_active_user` | `T` | Like `require_user`, but also checks `user.is_active` |
257
- | `require_roles(roles)` | `Callable` | Returns a dependency that demands the user has all specified roles |
258
- | `require_permissions(perms)` | `Callable` | Returns a dependency that demands the user has all specified permissions |
259
- | `login(sub, scopes?, extra?)` | `TokenResponse` | Issues access + refresh tokens for a given subject |
260
- | `get_security_scheme()` | `SecurityBase` | Returns the OpenAPI security scheme for docs |
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 |
261
303
 
262
304
  ### `UserLoader` Protocol
263
305
 
@@ -279,6 +321,24 @@ class MyUserLoader:
279
321
  return await self.db.get_user(token_payload.sub)
280
322
  ```
281
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
+
282
342
  ### `TokenPayload`
283
343
 
284
344
  The decoded JWT structure. Accepts extra claims via `model_config = ConfigDict(extra="allow")`.
@@ -349,6 +409,78 @@ is_valid = verify_password("my-password", hashed) # True
349
409
 
350
410
  ---
351
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
+
352
484
  ## Custom Token Payload
353
485
 
354
486
  If you need custom claims in your tokens (e.g., `tenant_id`, `organization_id`), subclass `TokenPayload` and pass it to `AuthProvider`:
@@ -535,11 +667,12 @@ async def test_secure_me_endpoint():
535
667
 
536
668
  ### Testing utilities reference
537
669
 
538
- | Import | Description |
539
- | ----------------------------------------------------- | ------------------------------------------------------------ |
540
- | `build_fake_auth_provider(users?, config_overrides?)` | Creates an `AuthProvider` backed by in-memory fakes |
541
- | `fake_auth_config(**overrides)` | Returns an `AuthConfig` with safe test defaults |
542
- | `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)` |
543
676
 
544
677
  ---
545
678
 
@@ -612,7 +745,9 @@ FAuth raises `HTTPException` with standard HTTP status codes:
612
745
  | Expired token | `401` | `"Token expired"` |
613
746
  | Invalid/malformed token | `401` | `"Invalid token"` |
614
747
  | User not found | `401` | `"User does not exist"` |
615
- | Inactive user | `400` | `"Inactive user"` |
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()`) |
616
751
  | Missing role | `403` | `"Missing role: {role}"` |
617
752
  | Missing permission | `403` | `"Insufficient permissions: requires {permission} permission"` |
618
753