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.
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.1 → fauth-0.2.2}/.github/workflows/pre-commit-autoupdate.yml +8 -16
  4. {fauth-0.2.1 → fauth-0.2.2}/.pre-commit-config.yaml +1 -1
  5. {fauth-0.2.1 → fauth-0.2.2}/CHANGELOG.md +50 -0
  6. {fauth-0.2.1 → fauth-0.2.2}/PKG-INFO +161 -39
  7. {fauth-0.2.1 → fauth-0.2.2}/README.md +160 -38
  8. {fauth-0.2.1 → fauth-0.2.2}/pyproject.toml +1 -1
  9. {fauth-0.2.1 → fauth-0.2.2}/uv.lock +46 -46
  10. fauth-0.2.1/.github/workflows/cicd.yml +0 -181
  11. fauth-0.2.1/requirements.txt +0 -54
  12. {fauth-0.2.1 → fauth-0.2.2}/.github/dependabot.yml +0 -0
  13. {fauth-0.2.1 → fauth-0.2.2}/.gitignore +0 -0
  14. {fauth-0.2.1 → fauth-0.2.2}/LICENSE +0 -0
  15. {fauth-0.2.1 → fauth-0.2.2}/fauth/__init__.py +0 -0
  16. {fauth-0.2.1 → fauth-0.2.2}/fauth/api/__init__.py +0 -0
  17. {fauth-0.2.1 → fauth-0.2.2}/fauth/api/router.py +0 -0
  18. {fauth-0.2.1 → fauth-0.2.2}/fauth/core/__init__.py +0 -0
  19. {fauth-0.2.1 → fauth-0.2.2}/fauth/core/config.py +0 -0
  20. {fauth-0.2.1 → fauth-0.2.2}/fauth/core/exceptions.py +0 -0
  21. {fauth-0.2.1 → fauth-0.2.2}/fauth/core/schemas.py +0 -0
  22. {fauth-0.2.1 → fauth-0.2.2}/fauth/crypto/__init__.py +0 -0
  23. {fauth-0.2.1 → fauth-0.2.2}/fauth/crypto/jwt.py +0 -0
  24. {fauth-0.2.1 → fauth-0.2.2}/fauth/crypto/password.py +0 -0
  25. {fauth-0.2.1 → fauth-0.2.2}/fauth/providers/__init__.py +0 -0
  26. {fauth-0.2.1 → fauth-0.2.2}/fauth/providers/protocols.py +0 -0
  27. {fauth-0.2.1 → fauth-0.2.2}/fauth/providers/provider.py +0 -0
  28. {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/__init__.py +0 -0
  29. {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/config.py +0 -0
  30. {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/fakes.py +0 -0
  31. {fauth-0.2.1 → fauth-0.2.2}/fauth/testing/provider.py +0 -0
  32. {fauth-0.2.1 → fauth-0.2.2}/fauth/transports/__init__.py +0 -0
  33. {fauth-0.2.1 → fauth-0.2.2}/fauth/transports/base.py +0 -0
  34. {fauth-0.2.1 → fauth-0.2.2}/fauth/transports/bearer.py +0 -0
  35. {fauth-0.2.1 → fauth-0.2.2}/fauth/utils/__init__.py +0 -0
  36. {fauth-0.2.1 → fauth-0.2.2}/fauth/utils/logging.py +0 -0
  37. {fauth-0.2.1 → fauth-0.2.2}/pytest.ini +0 -0
  38. {fauth-0.2.1 → fauth-0.2.2}/tests/__init__.py +0 -0
  39. {fauth-0.2.1 → fauth-0.2.2}/tests/api/__init__.py +0 -0
  40. {fauth-0.2.1 → fauth-0.2.2}/tests/api/conftest.py +0 -0
  41. {fauth-0.2.1 → fauth-0.2.2}/tests/api/test_openapi.py +0 -0
  42. {fauth-0.2.1 → fauth-0.2.2}/tests/api/test_router.py +0 -0
  43. {fauth-0.2.1 → fauth-0.2.2}/tests/conftest.py +0 -0
  44. {fauth-0.2.1 → fauth-0.2.2}/tests/core/__init__.py +0 -0
  45. {fauth-0.2.1 → fauth-0.2.2}/tests/core/conftest.py +0 -0
  46. {fauth-0.2.1 → fauth-0.2.2}/tests/core/test_config.py +0 -0
  47. {fauth-0.2.1 → fauth-0.2.2}/tests/core/test_exceptions.py +0 -0
  48. {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/__init__.py +0 -0
  49. {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/conftest.py +0 -0
  50. {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/test_jwt.py +0 -0
  51. {fauth-0.2.1 → fauth-0.2.2}/tests/crypto/test_password.py +0 -0
  52. {fauth-0.2.1 → fauth-0.2.2}/tests/providers/__init__.py +0 -0
  53. {fauth-0.2.1 → fauth-0.2.2}/tests/providers/conftest.py +0 -0
  54. {fauth-0.2.1 → fauth-0.2.2}/tests/providers/test_provider.py +0 -0
  55. {fauth-0.2.1 → fauth-0.2.2}/tests/testing/__init__.py +0 -0
  56. {fauth-0.2.1 → fauth-0.2.2}/tests/testing/test_testing.py +0 -0
  57. {fauth-0.2.1 → fauth-0.2.2}/tests/utils/conftest.py +0 -0
  58. {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: # 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,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.1
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,15 +133,16 @@ 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
 
@@ -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 database
168
+ # Mock databases
151
169
  DB: dict[str, User] = {
152
- "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"],
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 how to fetch a user by username/email for login
160
- async def load_user_by_identity(identifier: str) -> User | None:
161
- # In a real app, this would query your database
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=load_user_by_identity
197
+ identity_loader=load_identity,
173
198
  )
174
199
 
175
200
  # --- Routes ---
176
201
 
177
202
  @app.post("/login")
178
- async def login():
179
- # 4. Use `auth.login` to issue tokens (password verification omitted for now)
180
- 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)
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
- 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()
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 | Returns | Description |
267
- | ----------------------------- | --------------- | ------------------------------------------------------------------------ |
268
- | `require_user` | `T` | FastAPI dependency — extracts and validates the token, loads the user |
269
- | `require_active_user` | `T` | Like `require_user`, but also checks `user.is_active` |
270
- | `require_roles(roles)` | `Callable` | Returns a dependency that demands the user has all specified roles |
271
- | `require_permissions(perms)` | `Callable` | Returns a dependency that demands the user has all specified permissions |
272
- | `login(sub, scopes?, extra?)` | `TokenResponse` | Issues access + refresh tokens for a given subject |
273
- | `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 |
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
- | 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()`) |
629
751
  | Missing role | `403` | `"Missing role: {role}"` |
630
752
  | Missing permission | `403` | `"Insufficient permissions: requires {permission} permission"` |
631
753