fauth 0.1.2__tar.gz → 0.2.0__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.1.2 → fauth-0.2.0}/.pre-commit-config.yaml +1 -1
- {fauth-0.1.2 → fauth-0.2.0}/CHANGELOG.md +27 -0
- fauth-0.2.0/PKG-INFO +631 -0
- fauth-0.2.0/README.md +598 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/providers/provider.py +24 -0
- fauth-0.2.0/fauth/utils/__init__.py +3 -0
- fauth-0.2.0/fauth/utils/logging.py +31 -0
- {fauth-0.1.2 → fauth-0.2.0}/pyproject.toml +5 -2
- {fauth-0.1.2 → fauth-0.2.0}/requirements.txt +2 -0
- fauth-0.2.0/tests/utils/conftest.py +22 -0
- fauth-0.2.0/tests/utils/test_logging.py +47 -0
- {fauth-0.1.2 → fauth-0.2.0}/uv.lock +12 -1
- fauth-0.1.2/PKG-INFO +0 -218
- fauth-0.1.2/README.md +0 -188
- {fauth-0.1.2 → fauth-0.2.0}/.github/dependabot.yml +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/.github/workflows/cicd.yml +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/.github/workflows/pre-commit-autoupdate.yml +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/.gitignore +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/LICENSE +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/api/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/api/router.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/core/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/core/config.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/core/exceptions.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/core/schemas.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/crypto/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/crypto/jwt.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/crypto/password.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/providers/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/providers/protocols.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/config.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/fakes.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/provider.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/transports/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/transports/base.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/fauth/transports/bearer.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/pytest.ini +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/api/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/api/conftest.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/api/test_openapi.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/api/test_router.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/conftest.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/core/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/core/conftest.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/core/test_config.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/core/test_exceptions.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/conftest.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/test_jwt.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/test_password.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/providers/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/providers/conftest.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/providers/test_provider.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/testing/__init__.py +0 -0
- {fauth-0.1.2 → fauth-0.2.0}/tests/testing/test_testing.py +0 -0
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.2.0 (2026-04-02)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **config**: Add logs and update docs
|
|
9
|
+
([`9d9e733`](https://github.com/justmatias/fauth/commit/9d9e7333dc8c541c41d5a80c3bf7bde52ad1f159))
|
|
10
|
+
|
|
11
|
+
### Chores
|
|
12
|
+
|
|
13
|
+
- **config**: Update pre-commit hooks
|
|
14
|
+
([`f4098cd`](https://github.com/justmatias/fauth/commit/f4098cda011496566cc751305ac7952f4469df03))
|
|
15
|
+
|
|
16
|
+
- **config**: Update requirements.txt
|
|
17
|
+
([`1bef7cc`](https://github.com/justmatias/fauth/commit/1bef7cc6aa3ee9026165ad5bb3e48fc526b853ab))
|
|
18
|
+
|
|
19
|
+
- **docs**: Clarify structlog configuration requirements
|
|
20
|
+
([`9f685cb`](https://github.com/justmatias/fauth/commit/9f685cb8b0190c0b7c9b8ca665b25f4e5816af36))
|
|
21
|
+
|
|
22
|
+
- **docs**: Update project dependencies in pyproject.toml
|
|
23
|
+
([`d49cdbf`](https://github.com/justmatias/fauth/commit/d49cdbf979d63a6b2fbe895c43984c96ae4b721d))
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
- **config**: Implement structured logging utility using structlog
|
|
28
|
+
([`d87f777`](https://github.com/justmatias/fauth/commit/d87f777795f85e3f2856ad937728f594bc4cc3c1))
|
|
29
|
+
|
|
30
|
+
|
|
4
31
|
## v0.1.2 (2026-04-01)
|
|
5
32
|
|
|
6
33
|
### Bug Fixes
|
fauth-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fauth
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Ergonomic, lightweight JWT authentication for FastAPI. Secure your routes instantly with plug-and-play dependency injection
|
|
5
|
+
Project-URL: Homepage, https://github.com/justmatias/fauth
|
|
6
|
+
Project-URL: Repository, https://github.com/justmatias/fauth
|
|
7
|
+
Project-URL: Issues, https://github.com/justmatias/fauth/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/justmatias/fauth/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://github.com/justmatias/fauth/blob/main/README.md
|
|
10
|
+
Author-email: Matias Gimenez <matiasgimenez.dev@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: authentication,fastapi,jwt,rbac,security
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Requires-Dist: fastapi>=0.100
|
|
27
|
+
Requires-Dist: pwdlib[argon2]>=0.2
|
|
28
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
29
|
+
Requires-Dist: pydantic>=2.0
|
|
30
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
31
|
+
Requires-Dist: structlog>=25.5.0
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# FAuth
|
|
35
|
+
|
|
36
|
+
An ergonomic, plug-and-play authentication library for FastAPI.
|
|
37
|
+
|
|
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
|
+
|
|
40
|
+
[](https://pypi.org/project/fauth/)
|
|
41
|
+
[](https://pypi.org/project/fauth/)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Protocol-Based User Fetching** — Complete inversion of control. You implement a simple `UserLoader` protocol to define how to fetch a user from a token payload.
|
|
47
|
+
- **Plug-and-Play Configuration** — Centralized settings via Pydantic (`AuthConfig`). Configure once, inject everywhere.
|
|
48
|
+
- **Pluggable Transports** — Extensible `Transport` protocol with a built-in `BearerTransport` for Authorization header tokens.
|
|
49
|
+
- **Automatic OpenAPI/Swagger UI Support** — Integrated security schemes that automatically show the "Authorize" button and security lock icons in Swagger UI.
|
|
50
|
+
- **Built-in Password Hashing & Crypto** — Modern Argon2 via `pwdlib` and utilities for creating/decoding JWT access and refresh tokens.
|
|
51
|
+
- **RBAC** — Flexible `require_roles` and `require_permissions` dependencies for endpoint authorization.
|
|
52
|
+
- **Secure Router** — `SecureAPIRouter` applies authentication as a router-level dependency, securing all its routes automatically.
|
|
53
|
+
- **Structured Logging** — Built-in `structlog`-based logging for authentication events, token operations, and security failures.
|
|
54
|
+
- **Testing Utilities** — Ships fake implementations (`FakeUserLoader`) and a `build_fake_auth_provider()` factory so consumers can write unit tests with zero boilerplate.
|
|
55
|
+
- **Type Safety** — Fully annotated for MyPy and IDE integration.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install fauth
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or with [uv](https://github.com/astral-sh/uv):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv add fauth
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
### 1. Define your user model
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pydantic import BaseModel
|
|
77
|
+
|
|
78
|
+
class User(BaseModel):
|
|
79
|
+
id: str
|
|
80
|
+
username: str
|
|
81
|
+
is_active: bool = True
|
|
82
|
+
roles: list[str] = []
|
|
83
|
+
permissions: list[str] = []
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 2. Implement the `UserLoader` protocol
|
|
87
|
+
|
|
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:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from fauth import TokenPayload
|
|
92
|
+
|
|
93
|
+
# Your database, ORM, or any data source
|
|
94
|
+
DB: dict[str, User] = {
|
|
95
|
+
"user-123": User(id="user-123", username="alice", roles=["admin"], permissions=["read", "write"]),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def load_user(payload: TokenPayload) -> User | None:
|
|
99
|
+
"""Look up a user by the `sub` claim from the JWT."""
|
|
100
|
+
return DB.get(payload.sub)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 3. Create the AuthProvider
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from fauth import AuthConfig, AuthProvider
|
|
107
|
+
|
|
108
|
+
config = AuthConfig(secret_key="my-super-secret-key")
|
|
109
|
+
auth: AuthProvider[User] = AuthProvider(config=config, user_loader=load_user)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 4. Wire it into FastAPI
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from fastapi import FastAPI, Depends
|
|
116
|
+
|
|
117
|
+
app = FastAPI()
|
|
118
|
+
|
|
119
|
+
@app.post("/login")
|
|
120
|
+
async def login():
|
|
121
|
+
return await auth.login(sub="user-123")
|
|
122
|
+
|
|
123
|
+
@app.get("/me")
|
|
124
|
+
async def get_me(user: User = Depends(auth.require_user)):
|
|
125
|
+
return {"message": f"Hello {user.username}"}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
That's it. The `/me` endpoint is now protected. Requests without a valid `Bearer` token will receive a `401 Unauthorized` response.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Full Example
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from fastapi import FastAPI, Depends
|
|
136
|
+
from pydantic import BaseModel
|
|
137
|
+
from fauth import AuthConfig, AuthProvider, TokenPayload, SecureAPIRouter
|
|
138
|
+
|
|
139
|
+
app = FastAPI()
|
|
140
|
+
|
|
141
|
+
# 1. Define your internal user model
|
|
142
|
+
class User(BaseModel):
|
|
143
|
+
id: str
|
|
144
|
+
username: str
|
|
145
|
+
is_active: bool = True
|
|
146
|
+
roles: list[str] = []
|
|
147
|
+
permissions: list[str] = []
|
|
148
|
+
|
|
149
|
+
# Mock database
|
|
150
|
+
DB: dict[str, User] = {
|
|
151
|
+
"user-123": User(id="user-123", username="alice", roles=["admin"], permissions=["read", "write"])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# 2. Define the callback that retrieves a user from the decoded JWT
|
|
155
|
+
async def load_user(payload: TokenPayload) -> User | None:
|
|
156
|
+
return DB.get(payload.sub)
|
|
157
|
+
|
|
158
|
+
# 3. Instantiate the auth component
|
|
159
|
+
config = AuthConfig(secret_key="my-super-secret-key", algorithm="HS256")
|
|
160
|
+
auth: AuthProvider[User] = AuthProvider(config=config, user_loader=load_user)
|
|
161
|
+
|
|
162
|
+
# --- Routes ---
|
|
163
|
+
|
|
164
|
+
@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")
|
|
168
|
+
|
|
169
|
+
@app.get("/me")
|
|
170
|
+
async def get_me(user: User = Depends(auth.require_user)):
|
|
171
|
+
# 5. `auth.require_user` secures the endpoint automatically
|
|
172
|
+
return {"message": f"Hello {user.username}"}
|
|
173
|
+
|
|
174
|
+
@app.get("/admin")
|
|
175
|
+
async def get_admin_data(user: User = Depends(auth.require_roles(["admin"]))):
|
|
176
|
+
# 6. `auth.require_roles` enforces RBAC with list of roles
|
|
177
|
+
return {"secret_data": "Top secret admin info"}
|
|
178
|
+
|
|
179
|
+
# --- Securing Multiple Routes ---
|
|
180
|
+
|
|
181
|
+
# 7. Use `SecureAPIRouter` to protect an entire group of routes.
|
|
182
|
+
# Any route added to this router will require an active user automatically.
|
|
183
|
+
# This also enables the "Authorize" button in Swagger UI!
|
|
184
|
+
secure_router = SecureAPIRouter(auth_provider=auth, prefix="/internal", tags=["Protected"])
|
|
185
|
+
|
|
186
|
+
@secure_router.get("/dashboard")
|
|
187
|
+
async def get_dashboard():
|
|
188
|
+
# This endpoint is secured by FAuth without needing Depends in the signature!
|
|
189
|
+
return {"data": "Secure dashboard"}
|
|
190
|
+
|
|
191
|
+
app.include_router(secure_router)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## API Reference
|
|
197
|
+
|
|
198
|
+
### `AuthConfig`
|
|
199
|
+
|
|
200
|
+
Centralized authentication settings, powered by [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/). Supports loading values from environment variables out of the box.
|
|
201
|
+
|
|
202
|
+
| Parameter | Type | Default | Description |
|
|
203
|
+
| ------------------------------ | ------------------- | ---------------- | -------------------------------- |
|
|
204
|
+
| `secret_key` | `str` | _required_ | Secret key used for signing JWTs |
|
|
205
|
+
| `algorithm` | `str` | `"HS256"` | JWT signing algorithm |
|
|
206
|
+
| `access_token_expire_minutes` | `int` | `15` | Access token TTL in minutes |
|
|
207
|
+
| `refresh_token_expire_minutes` | `int` | `10080` (7 days) | Refresh token TTL in minutes |
|
|
208
|
+
| `token_type` | `Literal["bearer"]` | `"bearer"` | Token type for responses |
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from fauth import AuthConfig
|
|
212
|
+
|
|
213
|
+
# Minimal — only secret_key is required
|
|
214
|
+
config = AuthConfig(secret_key="my-secret-key")
|
|
215
|
+
|
|
216
|
+
# Full control
|
|
217
|
+
config = AuthConfig(
|
|
218
|
+
secret_key="my-secret-key",
|
|
219
|
+
algorithm="HS256",
|
|
220
|
+
access_token_expire_minutes=30,
|
|
221
|
+
refresh_token_expire_minutes=60 * 24, # 1 day
|
|
222
|
+
)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Since `AuthConfig` extends `BaseSettings`, you can also load from environment variables:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
export SECRET_KEY="my-secret-from-env"
|
|
229
|
+
export ACCESS_TOKEN_EXPIRE_MINUTES=60
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
config = AuthConfig() # Reads from environment
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### `AuthProvider[T]`
|
|
237
|
+
|
|
238
|
+
The main orchestrator. Provides FastAPI dependencies for authentication and authorization.
|
|
239
|
+
|
|
240
|
+
#### Constructor
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
AuthProvider(
|
|
244
|
+
config: AuthConfig,
|
|
245
|
+
user_loader: UserLoader[T],
|
|
246
|
+
transport: Transport | None = None, # Defaults to BearerTransport()
|
|
247
|
+
token_payload_schema: type[TokenPayload] = TokenPayload,
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
#### Methods
|
|
252
|
+
|
|
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 |
|
|
261
|
+
|
|
262
|
+
### `UserLoader` Protocol
|
|
263
|
+
|
|
264
|
+
Your application implements this to tell FAuth how to fetch a user from a decoded JWT:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
from fauth import TokenPayload
|
|
268
|
+
|
|
269
|
+
# As a plain function
|
|
270
|
+
async def load_user(token_payload: TokenPayload) -> User | None:
|
|
271
|
+
return await db.get_user(token_payload.sub)
|
|
272
|
+
|
|
273
|
+
# Or as a callable class
|
|
274
|
+
class MyUserLoader:
|
|
275
|
+
def __init__(self, db: Database):
|
|
276
|
+
self.db = db
|
|
277
|
+
|
|
278
|
+
async def __call__(self, token_payload: TokenPayload) -> User | None:
|
|
279
|
+
return await self.db.get_user(token_payload.sub)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### `TokenPayload`
|
|
283
|
+
|
|
284
|
+
The decoded JWT structure. Accepts extra claims via `model_config = ConfigDict(extra="allow")`.
|
|
285
|
+
|
|
286
|
+
| Field | Type | Description |
|
|
287
|
+
| ------------ | ------------------------------ | ---------------------------------------- |
|
|
288
|
+
| `sub` | `str` | Subject (typically user ID) |
|
|
289
|
+
| `exp` | `int` | Expiry timestamp |
|
|
290
|
+
| `iat` | `int` | Issued-at timestamp |
|
|
291
|
+
| `jti` | `str` | Unique token ID |
|
|
292
|
+
| `scopes` | `list[str]` | Token scopes (defaults to `[]`) |
|
|
293
|
+
| `token_type` | `Literal["access", "refresh"]` | Distinguishes access from refresh tokens |
|
|
294
|
+
|
|
295
|
+
### `TokenResponse`
|
|
296
|
+
|
|
297
|
+
Returned by `auth.login()`:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
{
|
|
301
|
+
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
302
|
+
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
303
|
+
"token_type": "bearer"
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Crypto Utilities
|
|
310
|
+
|
|
311
|
+
FAuth exposes standalone functions for direct use outside the `AuthProvider`:
|
|
312
|
+
|
|
313
|
+
### JWT
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
from fauth import create_access_token, create_refresh_token, decode_token, AuthConfig
|
|
317
|
+
|
|
318
|
+
config = AuthConfig(secret_key="my-secret")
|
|
319
|
+
|
|
320
|
+
# Create tokens
|
|
321
|
+
access = create_access_token(sub="user-123", config=config)
|
|
322
|
+
refresh = create_refresh_token(sub="user-123", config=config)
|
|
323
|
+
|
|
324
|
+
# With scopes and extra claims
|
|
325
|
+
access = create_access_token(
|
|
326
|
+
sub="user-123",
|
|
327
|
+
config=config,
|
|
328
|
+
scopes=["read", "write"],
|
|
329
|
+
extra={"tenant_id": "acme"},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Decode
|
|
333
|
+
payload = decode_token(access, config)
|
|
334
|
+
print(payload.sub) # "user-123"
|
|
335
|
+
print(payload.token_type) # "access"
|
|
336
|
+
print(payload.scopes) # ["read", "write"]
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Password Hashing
|
|
340
|
+
|
|
341
|
+
Uses Argon2 via [`pwdlib`](https://github.com/frankie567/pwdlib):
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
from fauth import hash_password, verify_password
|
|
345
|
+
|
|
346
|
+
hashed = hash_password("my-password")
|
|
347
|
+
is_valid = verify_password("my-password", hashed) # True
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Custom Token Payload
|
|
353
|
+
|
|
354
|
+
If you need custom claims in your tokens (e.g., `tenant_id`, `organization_id`), subclass `TokenPayload` and pass it to `AuthProvider`:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
from fauth import AuthConfig, AuthProvider, TokenPayload
|
|
358
|
+
|
|
359
|
+
class MyTokenPayload(TokenPayload):
|
|
360
|
+
tenant_id: str
|
|
361
|
+
plan: str = "free"
|
|
362
|
+
|
|
363
|
+
auth = AuthProvider(
|
|
364
|
+
config=AuthConfig(secret_key="my-secret"),
|
|
365
|
+
user_loader=load_user,
|
|
366
|
+
token_payload_schema=MyTokenPayload, # JWTs will be decoded into MyTokenPayload
|
|
367
|
+
)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
When issuing tokens, pass custom claims via the `extra` parameter:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
await auth.login(sub="user-123", extra={"tenant_id": "acme", "plan": "pro"})
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Your `user_loader` will then receive a `MyTokenPayload` instance with typed access to `payload.tenant_id` and `payload.plan`.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## RBAC (Roles & Permissions)
|
|
381
|
+
|
|
382
|
+
### Requiring Roles
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
@app.get("/admin")
|
|
386
|
+
async def admin_panel(user: User = Depends(auth.require_roles(["admin"]))):
|
|
387
|
+
return {"message": "Welcome, admin"}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Returns `403 Forbidden` with `{"detail": "Missing role: admin"}` if the user lacks the role.
|
|
391
|
+
|
|
392
|
+
### Requiring Permissions
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
@app.get("/reports")
|
|
396
|
+
async def reports(user: User = Depends(auth.require_permissions(["read", "reports"]))):
|
|
397
|
+
return {"data": "..."}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Returns `403 Forbidden` with `{"detail": "Insufficient permissions: requires read permission"}` if the user lacks any of the required permissions.
|
|
401
|
+
|
|
402
|
+
> **Note:** FAuth reads roles/permissions from `user.roles` and `user.permissions` attributes respectively. Make sure your user model exposes these fields.
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Custom Transports
|
|
407
|
+
|
|
408
|
+
By default, FAuth uses `BearerTransport`, which extracts the token from the `Authorization: Bearer <token>` header. You can implement the `Transport` protocol to support other strategies (e.g., cookies):
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
from fastapi import Request, Response
|
|
412
|
+
from fastapi.security.base import SecurityBase
|
|
413
|
+
from fauth import Transport
|
|
414
|
+
|
|
415
|
+
class CookieTransport:
|
|
416
|
+
async def __call__(self, request: Request) -> str | None:
|
|
417
|
+
return request.cookies.get("auth_token")
|
|
418
|
+
|
|
419
|
+
def set_token_response(self, response: Response, token: str) -> None:
|
|
420
|
+
response.set_cookie("auth_token", token, httponly=True, samesite="lax")
|
|
421
|
+
|
|
422
|
+
def clear_token_response(self, response: Response) -> None:
|
|
423
|
+
response.delete_cookie("auth_token")
|
|
424
|
+
|
|
425
|
+
def get_security_scheme(self) -> SecurityBase:
|
|
426
|
+
# Return your custom OpenAPI scheme
|
|
427
|
+
...
|
|
428
|
+
|
|
429
|
+
# Use it
|
|
430
|
+
auth = AuthProvider(config=config, user_loader=load_user, transport=CookieTransport())
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## SecureAPIRouter
|
|
436
|
+
|
|
437
|
+
`SecureAPIRouter` is a drop-in replacement for `APIRouter` that automatically applies authentication to **all** its routes. It also registers the security scheme in OpenAPI so the "Authorize" button appears in Swagger UI.
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
from fauth import SecureAPIRouter
|
|
441
|
+
|
|
442
|
+
secure_router = SecureAPIRouter(
|
|
443
|
+
auth_provider=auth,
|
|
444
|
+
prefix="/api/v1",
|
|
445
|
+
tags=["Protected"],
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
@secure_router.get("/dashboard")
|
|
449
|
+
async def dashboard():
|
|
450
|
+
# Automatically secured — no Depends needed in the function signature
|
|
451
|
+
return {"data": "protected content"}
|
|
452
|
+
|
|
453
|
+
@secure_router.get("/settings")
|
|
454
|
+
async def settings():
|
|
455
|
+
return {"theme": "dark"}
|
|
456
|
+
|
|
457
|
+
app.include_router(secure_router)
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Testing
|
|
463
|
+
|
|
464
|
+
FAuth ships a `fauth.testing` module to simplify testing. No complex JWT mocks or real database dependencies needed.
|
|
465
|
+
|
|
466
|
+
### Dependency Override (recommended for unit tests)
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
import pytest
|
|
470
|
+
from fastapi.testclient import TestClient
|
|
471
|
+
from pydantic import BaseModel
|
|
472
|
+
|
|
473
|
+
from myapp.main import app, auth
|
|
474
|
+
from fauth.testing import build_fake_auth_provider
|
|
475
|
+
|
|
476
|
+
class User(BaseModel):
|
|
477
|
+
id: str
|
|
478
|
+
username: str
|
|
479
|
+
is_active: bool = True
|
|
480
|
+
roles: list[str] = []
|
|
481
|
+
|
|
482
|
+
@pytest.fixture
|
|
483
|
+
def test_client() -> TestClient:
|
|
484
|
+
# 1. Provide a mock test user
|
|
485
|
+
mock_user = User(id="user-123", username="test_user", roles=["admin"])
|
|
486
|
+
|
|
487
|
+
# 2. Wire the fake provider with an in-memory user store
|
|
488
|
+
fake = build_fake_auth_provider(users={"user-123": mock_user})
|
|
489
|
+
|
|
490
|
+
# 3. Override the dependency functions
|
|
491
|
+
app.dependency_overrides[auth.require_user] = fake.require_user
|
|
492
|
+
app.dependency_overrides[auth.require_active_user] = fake.require_active_user
|
|
493
|
+
|
|
494
|
+
yield TestClient(app)
|
|
495
|
+
|
|
496
|
+
# Clean up overrides
|
|
497
|
+
app.dependency_overrides.clear()
|
|
498
|
+
|
|
499
|
+
def test_secure_route(test_client):
|
|
500
|
+
response = test_client.get("/me")
|
|
501
|
+
assert response.status_code == 200
|
|
502
|
+
assert response.json() == {"message": "Hello test_user"}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### End-to-end testing with real JWT tokens
|
|
506
|
+
|
|
507
|
+
If you prefer issuing real tokens in tests, `build_fake_auth_provider` uses safe test defaults (a fixed secret key, short expiry):
|
|
508
|
+
|
|
509
|
+
```python
|
|
510
|
+
@pytest.mark.asyncio
|
|
511
|
+
async def test_secure_me_endpoint():
|
|
512
|
+
user = User(id="user-999", username="fake_alice", roles=[])
|
|
513
|
+
|
|
514
|
+
# FAuth supplies a pre-made test provider with safe defaults
|
|
515
|
+
test_auth = build_fake_auth_provider(users={"user-999": user})
|
|
516
|
+
|
|
517
|
+
# Generate a real JWT token via the test provider
|
|
518
|
+
token_response = await test_auth.login(sub="user-999")
|
|
519
|
+
|
|
520
|
+
# Override the dependency so the app uses the test user store
|
|
521
|
+
app.dependency_overrides[auth.require_user] = test_auth.require_user
|
|
522
|
+
|
|
523
|
+
# Apply Bearer token
|
|
524
|
+
client = TestClient(app)
|
|
525
|
+
response = client.get(
|
|
526
|
+
"/me",
|
|
527
|
+
headers={"Authorization": f"Bearer {token_response.access_token}"}
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
assert response.status_code == 200
|
|
531
|
+
assert response.json() == {"message": "Hello fake_alice"}
|
|
532
|
+
|
|
533
|
+
app.dependency_overrides.clear()
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### Testing utilities reference
|
|
537
|
+
|
|
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)` |
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## Structured Logging
|
|
547
|
+
|
|
548
|
+
FAuth uses [`structlog`](https://www.structlog.org/) for structured logging across all security-sensitive operations. **FAuth does not call `structlog.configure()`** — your application owns the processor pipeline. If you never configure structlog, the default `dev` renderer is used (coloured, human-readable text).
|
|
549
|
+
|
|
550
|
+
### Configuring log output
|
|
551
|
+
|
|
552
|
+
Configure structlog once in your application startup — FAuth (and any other structlog-based library) will follow:
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
import structlog
|
|
556
|
+
|
|
557
|
+
# Development — human-readable coloured text
|
|
558
|
+
structlog.configure(
|
|
559
|
+
processors=[
|
|
560
|
+
structlog.processors.add_log_level,
|
|
561
|
+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False),
|
|
562
|
+
structlog.dev.ConsoleRenderer(),
|
|
563
|
+
],
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Production — JSON lines for log aggregators (Datadog, ELK, etc.)
|
|
567
|
+
structlog.configure(
|
|
568
|
+
processors=[
|
|
569
|
+
structlog.processors.add_log_level,
|
|
570
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
571
|
+
structlog.processors.JSONRenderer(),
|
|
572
|
+
],
|
|
573
|
+
)
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### What gets logged
|
|
577
|
+
|
|
578
|
+
| Event | Level | Context |
|
|
579
|
+
| ----------------------- | --------- | ------------------------------------------------------------------------------ |
|
|
580
|
+
| `login_token_issued` | `info` | `sub` |
|
|
581
|
+
| `token_decoded` | `debug` | `sub`, `token_type` |
|
|
582
|
+
| `user_authenticated` | `debug` | `sub` |
|
|
583
|
+
| `authentication_failed` | `warning` | `reason` (`missing_token`, `token_expired`, `invalid_token`, `user_not_found`) |
|
|
584
|
+
| `authorization_failed` | `warning` | `reason` (`inactive_user`, `missing_role`, `missing_permission`) |
|
|
585
|
+
|
|
586
|
+
### Example log output
|
|
587
|
+
|
|
588
|
+
With `ConsoleRenderer()` (default):
|
|
589
|
+
|
|
590
|
+
```
|
|
591
|
+
2026-04-01 10:30:15 [info ] login_token_issued sub=user-123
|
|
592
|
+
2026-04-01 10:30:16 [debug ] token_decoded sub=user-123 token_type=access
|
|
593
|
+
2026-04-01 10:31:00 [warning ] authentication_failed reason=token_expired
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
With `JSONRenderer()`:
|
|
597
|
+
|
|
598
|
+
```json
|
|
599
|
+
{"sub": "user-123", "event": "login_token_issued", "level": "info", "timestamp": "2026-04-01T13:30:15Z"}
|
|
600
|
+
{"reason": "token_expired", "event": "authentication_failed", "level": "warning", "timestamp": "2026-04-01T13:31:00Z"}
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
## Error Handling
|
|
606
|
+
|
|
607
|
+
FAuth raises `HTTPException` with standard HTTP status codes:
|
|
608
|
+
|
|
609
|
+
| Scenario | Status Code | Detail |
|
|
610
|
+
| ----------------------- | ----------- | -------------------------------------------------------------- |
|
|
611
|
+
| Missing token | `401` | `"Not authenticated"` |
|
|
612
|
+
| Expired token | `401` | `"Token expired"` |
|
|
613
|
+
| Invalid/malformed token | `401` | `"Invalid token"` |
|
|
614
|
+
| User not found | `401` | `"User does not exist"` |
|
|
615
|
+
| Inactive user | `400` | `"Inactive user"` |
|
|
616
|
+
| Missing role | `403` | `"Missing role: {role}"` |
|
|
617
|
+
| Missing permission | `403` | `"Insufficient permissions: requires {permission} permission"` |
|
|
618
|
+
|
|
619
|
+
For programmatic exception handling, FAuth also exposes:
|
|
620
|
+
|
|
621
|
+
```python
|
|
622
|
+
from fauth import FAuthError, InvalidTokenError, TokenExpiredError
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
These are raised by the crypto layer (`decode_token`) and can be caught independently of HTTP responses.
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
## License
|
|
630
|
+
|
|
631
|
+
MIT
|