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.
Files changed (58) hide show
  1. {fauth-0.1.2 → fauth-0.2.0}/.pre-commit-config.yaml +1 -1
  2. {fauth-0.1.2 → fauth-0.2.0}/CHANGELOG.md +27 -0
  3. fauth-0.2.0/PKG-INFO +631 -0
  4. fauth-0.2.0/README.md +598 -0
  5. {fauth-0.1.2 → fauth-0.2.0}/fauth/providers/provider.py +24 -0
  6. fauth-0.2.0/fauth/utils/__init__.py +3 -0
  7. fauth-0.2.0/fauth/utils/logging.py +31 -0
  8. {fauth-0.1.2 → fauth-0.2.0}/pyproject.toml +5 -2
  9. {fauth-0.1.2 → fauth-0.2.0}/requirements.txt +2 -0
  10. fauth-0.2.0/tests/utils/conftest.py +22 -0
  11. fauth-0.2.0/tests/utils/test_logging.py +47 -0
  12. {fauth-0.1.2 → fauth-0.2.0}/uv.lock +12 -1
  13. fauth-0.1.2/PKG-INFO +0 -218
  14. fauth-0.1.2/README.md +0 -188
  15. {fauth-0.1.2 → fauth-0.2.0}/.github/dependabot.yml +0 -0
  16. {fauth-0.1.2 → fauth-0.2.0}/.github/workflows/cicd.yml +0 -0
  17. {fauth-0.1.2 → fauth-0.2.0}/.github/workflows/pre-commit-autoupdate.yml +0 -0
  18. {fauth-0.1.2 → fauth-0.2.0}/.gitignore +0 -0
  19. {fauth-0.1.2 → fauth-0.2.0}/LICENSE +0 -0
  20. {fauth-0.1.2 → fauth-0.2.0}/fauth/__init__.py +0 -0
  21. {fauth-0.1.2 → fauth-0.2.0}/fauth/api/__init__.py +0 -0
  22. {fauth-0.1.2 → fauth-0.2.0}/fauth/api/router.py +0 -0
  23. {fauth-0.1.2 → fauth-0.2.0}/fauth/core/__init__.py +0 -0
  24. {fauth-0.1.2 → fauth-0.2.0}/fauth/core/config.py +0 -0
  25. {fauth-0.1.2 → fauth-0.2.0}/fauth/core/exceptions.py +0 -0
  26. {fauth-0.1.2 → fauth-0.2.0}/fauth/core/schemas.py +0 -0
  27. {fauth-0.1.2 → fauth-0.2.0}/fauth/crypto/__init__.py +0 -0
  28. {fauth-0.1.2 → fauth-0.2.0}/fauth/crypto/jwt.py +0 -0
  29. {fauth-0.1.2 → fauth-0.2.0}/fauth/crypto/password.py +0 -0
  30. {fauth-0.1.2 → fauth-0.2.0}/fauth/providers/__init__.py +0 -0
  31. {fauth-0.1.2 → fauth-0.2.0}/fauth/providers/protocols.py +0 -0
  32. {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/__init__.py +0 -0
  33. {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/config.py +0 -0
  34. {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/fakes.py +0 -0
  35. {fauth-0.1.2 → fauth-0.2.0}/fauth/testing/provider.py +0 -0
  36. {fauth-0.1.2 → fauth-0.2.0}/fauth/transports/__init__.py +0 -0
  37. {fauth-0.1.2 → fauth-0.2.0}/fauth/transports/base.py +0 -0
  38. {fauth-0.1.2 → fauth-0.2.0}/fauth/transports/bearer.py +0 -0
  39. {fauth-0.1.2 → fauth-0.2.0}/pytest.ini +0 -0
  40. {fauth-0.1.2 → fauth-0.2.0}/tests/__init__.py +0 -0
  41. {fauth-0.1.2 → fauth-0.2.0}/tests/api/__init__.py +0 -0
  42. {fauth-0.1.2 → fauth-0.2.0}/tests/api/conftest.py +0 -0
  43. {fauth-0.1.2 → fauth-0.2.0}/tests/api/test_openapi.py +0 -0
  44. {fauth-0.1.2 → fauth-0.2.0}/tests/api/test_router.py +0 -0
  45. {fauth-0.1.2 → fauth-0.2.0}/tests/conftest.py +0 -0
  46. {fauth-0.1.2 → fauth-0.2.0}/tests/core/__init__.py +0 -0
  47. {fauth-0.1.2 → fauth-0.2.0}/tests/core/conftest.py +0 -0
  48. {fauth-0.1.2 → fauth-0.2.0}/tests/core/test_config.py +0 -0
  49. {fauth-0.1.2 → fauth-0.2.0}/tests/core/test_exceptions.py +0 -0
  50. {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/__init__.py +0 -0
  51. {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/conftest.py +0 -0
  52. {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/test_jwt.py +0 -0
  53. {fauth-0.1.2 → fauth-0.2.0}/tests/crypto/test_password.py +0 -0
  54. {fauth-0.1.2 → fauth-0.2.0}/tests/providers/__init__.py +0 -0
  55. {fauth-0.1.2 → fauth-0.2.0}/tests/providers/conftest.py +0 -0
  56. {fauth-0.1.2 → fauth-0.2.0}/tests/providers/test_provider.py +0 -0
  57. {fauth-0.1.2 → fauth-0.2.0}/tests/testing/__init__.py +0 -0
  58. {fauth-0.1.2 → fauth-0.2.0}/tests/testing/test_testing.py +0 -0
@@ -44,7 +44,7 @@ repos:
44
44
  - fastapi==0.135.1
45
45
 
46
46
  - repo: https://github.com/pre-commit/mirrors-mypy
47
- rev: v1.19.1
47
+ rev: v1.20.0
48
48
  hooks:
49
49
  - id: mypy
50
50
  additional_dependencies:
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/fauth)](https://pypi.org/project/fauth/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/fauth)](https://pypi.org/project/fauth/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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