fastapi-fullauth 0.2.0__tar.gz → 0.3.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 (84) hide show
  1. fastapi_fullauth-0.3.0/CHANGELOG.md +87 -0
  2. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/PKG-INFO +52 -31
  3. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/README.md +51 -7
  4. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/auth.py +2 -2
  5. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/__init__.py +3 -3
  6. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/base.py +3 -0
  7. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/memory.py +5 -0
  8. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlalchemy/adapter.py +9 -0
  9. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlmodel/adapter.py +9 -0
  10. fastapi_fullauth-0.3.0/fastapi_fullauth/core/crypto.py +47 -0
  11. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/core/tokens.py +20 -9
  12. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/dependencies/__init__.py +6 -7
  13. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/dependencies/current_user.py +25 -15
  14. fastapi_fullauth-0.3.0/fastapi_fullauth/dependencies/require_role.py +33 -0
  15. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/login.py +13 -9
  16. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/password_reset.py +3 -0
  17. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/register.py +8 -0
  18. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/fullauth.py +40 -20
  19. fastapi_fullauth-0.3.0/fastapi_fullauth/hooks.py +50 -0
  20. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/protection/ratelimit.py +8 -4
  21. fastapi_fullauth-0.3.0/fastapi_fullauth/router/auth.py +505 -0
  22. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/types.py +20 -15
  23. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/pyproject.toml +1 -17
  24. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_customization.py +2 -5
  25. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_dx_improvements.py +4 -14
  26. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_email_verify.py +2 -5
  27. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_tokens.py +7 -5
  28. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/uv.lock +2 -379
  29. fastapi_fullauth-0.2.0/CHANGELOG.md +0 -47
  30. fastapi_fullauth-0.2.0/fastapi_fullauth/core/crypto.py +0 -19
  31. fastapi_fullauth-0.2.0/fastapi_fullauth/dependencies/require_role.py +0 -78
  32. fastapi_fullauth-0.2.0/fastapi_fullauth/hooks.py +0 -16
  33. fastapi_fullauth-0.2.0/fastapi_fullauth/router/auth.py +0 -486
  34. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.github/workflows/ci.yml +0 -0
  35. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.github/workflows/publish.yml +0 -0
  36. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.gitignore +0 -0
  37. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.python-version +0 -0
  38. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/LICENSE +0 -0
  39. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/__init__.py +0 -0
  40. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/auth.py +0 -0
  41. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/main.py +0 -0
  42. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/routes.py +0 -0
  43. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/__init__.py +0 -0
  44. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/config.py +0 -0
  45. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/main.py +0 -0
  46. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/models.py +0 -0
  47. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/routes.py +0 -0
  48. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/__init__.py +0 -0
  49. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlalchemy/__init__.py +0 -0
  50. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlalchemy/models.py +0 -0
  51. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlmodel/__init__.py +0 -0
  52. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlmodel/models.py +0 -0
  53. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/__init__.py +0 -0
  54. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/base.py +0 -0
  55. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/bearer.py +0 -0
  56. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/cookie.py +0 -0
  57. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/config.py +0 -0
  58. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/core/__init__.py +0 -0
  59. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/core/redis_blacklist.py +0 -0
  60. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/exceptions.py +0 -0
  61. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/__init__.py +0 -0
  62. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/email_verify.py +0 -0
  63. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/logout.py +0 -0
  64. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/__init__.py +0 -0
  65. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/csrf.py +0 -0
  66. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/ratelimit.py +0 -0
  67. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/security_headers.py +0 -0
  68. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/migrations/__init__.py +0 -0
  69. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/migrations/helpers.py +0 -0
  70. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/protection/__init__.py +0 -0
  71. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/protection/lockout.py +0 -0
  72. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/rbac/__init__.py +0 -0
  73. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/router/__init__.py +0 -0
  74. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/utils.py +0 -0
  75. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/validators.py +0 -0
  76. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/__init__.py +0 -0
  77. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/conftest.py +0 -0
  78. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_auth_flows.py +0 -0
  79. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_crypto.py +0 -0
  80. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_lockout.py +0 -0
  81. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_middleware.py +0 -0
  82. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_new_endpoints.py +0 -0
  83. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_refresh_tokens.py +0 -0
  84. {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_roles.py +0 -0
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ ### Breaking changes
6
+
7
+ - **`create_refresh_token` returns `RefreshTokenMeta`** — previously returned a plain `str`. Now returns a `NamedTuple` with `.token`, `.expires_at`, `.family_id`. Callers that used the raw string must access `.token`.
8
+ - **`create_token_pair` returns `tuple[str, RefreshTokenMeta]`** — second element is now `RefreshTokenMeta` instead of `str`.
9
+ - **`revoke_all_user_refresh_tokens` is now required** on custom adapters — new abstract method on `AbstractUserAdapter`.
10
+
11
+ ### Added
12
+
13
+ - `current_superuser` dependency and `SuperUser` annotated type
14
+ - `CurrentUser`, `VerifiedUser`, `SuperUser` annotated types in `dependencies.current_user` for cleaner route signatures
15
+ - `RefreshTokenMeta` named tuple — avoids decoding freshly created tokens just to read `expires_at` and `family_id`
16
+ - `FullAuth.get_custom_claims(user)` — moved custom claims logic from router into the class, with validation against reserved JWT keys (`sub`, `exp`, `type`, etc.)
17
+ - `revoke_all_user_refresh_tokens(user_id)` on all adapters — bulk session revocation
18
+ - Session revocation on password reset, password change, and account deletion
19
+ - `configure_hasher()` — wires `PASSWORD_HASH_ALGORITHM` config to the actual hasher; supports `argon2id` and `bcrypt`
20
+ - Automatic password rehash on login when hash algorithm or params have changed
21
+ - Register now checks uniqueness on `login_field` (not just email) when `login_field != "email"`
22
+ - `InMemoryBlacklist` now respects `ttl_seconds` — expired entries are evicted on lookup
23
+ - `RateLimiter` evicts keys with empty timestamp lists to prevent unbounded dict growth
24
+ - `description` parameter on all route decorators for Swagger docs
25
+
26
+ ### Fixed
27
+
28
+ - `current_active_verified_user` was missing `payload.type != "access"` check — refresh tokens could pass through
29
+ - Purpose tokens (password reset, email verify) could be used as regular access tokens — `current_user` now rejects tokens with `extra.purpose`
30
+ - Duplicate token decode + user lookup across dependencies, router endpoints, and admin routes — consolidated into reusable `current_user` dependency chain
31
+ - Duplicate `roles` + `extra_claims` fetch in refresh route — pulled above the if/else branch
32
+ - Login flow fetched the user from DB twice (once in router, once in `login()`) — now accepts pre-fetched user
33
+ - Unused `request: Request` parameters in dependencies and routes
34
+ - Removed duplicate docstrings on routes (kept `description=` on decorators)
35
+ - `require_permission` was a full copy of `require_role` — now delegates to it
36
+
37
+ ### Internal
38
+
39
+ - Route order follows auth lifecycle: register → login → refresh → logout → user → email/password → admin
40
+ - `require_role` / `require_permission` use `Depends(current_user)` instead of duplicating token logic
41
+ - Removed `_get_custom_claims` module-level function from router
42
+
43
+ ## 0.2.0
44
+
45
+ ### Breaking changes
46
+
47
+ - **JSON login** — `POST /login` now accepts `{"email": "...", "password": "..."}` instead of form data. Swagger auth uses bearer token input instead of username/password form.
48
+ - **No default User model** — SQLModel and SQLAlchemy adapters no longer ship a concrete `User`/`UserModel` table class. Users must define their own model from `UserBase`. This eliminates relationship conflicts when subclassing.
49
+ - **`user_model` is required** — `SQLModelAdapter(session_maker, user_model=MyUser)` — no default.
50
+ - **Removed `min_length=8`** from `CreateUserSchema` — password length is now fully controlled by `PasswordValidator` and `PASSWORD_MIN_LENGTH` config.
51
+ - **`SQLAlchemyAdapter` renamed `UserModel` to `UserBase`** — import `UserBase` instead.
52
+
53
+ ### Added
54
+
55
+ - `POST /auth/change-password` — verifies current password, validates new
56
+ - `PATCH /auth/me` — update profile with protected field filtering
57
+ - `DELETE /auth/me` — self-deletion
58
+ - `expires_in` in login/refresh responses
59
+ - Per-IP auth rate limiting on login, register, password-reset (`AUTH_RATE_LIMIT_*` config)
60
+ - `LOGIN_FIELD` config — login by email, username, phone, or any model field
61
+ - `get_user_by_field()` on all adapters for generic field lookups
62
+ - Structured example apps (`examples/memory_app/`, `examples/sqlmodel_app/`)
63
+
64
+ ### Fixed
65
+
66
+ - `InMemoryAdapter.update_user` returning base `UserSchema` instead of custom schema
67
+ - Stale `User.id` / `UserModel` references in adapter queries after model removal
68
+ - Parameter ordering in adapter constructors (required params before optional)
69
+
70
+ ## 0.1.0
71
+
72
+ Initial release.
73
+
74
+ - JWT access/refresh tokens with rotation and blacklisting
75
+ - Argon2id password hashing
76
+ - Auth flows: register, login, logout, password reset, email verification
77
+ - Brute-force lockout, per-IP rate limiting, CSRF, security headers
78
+ - Bearer and cookie backends
79
+ - SQLAlchemy, SQLModel, and InMemory adapters
80
+ - Redis blacklist backend
81
+ - Refresh token persistence with family tracking and reuse detection
82
+ - Flat config (`secret_key=...`) or full `FullAuthConfig` object
83
+ - Auto-derive schemas from ORM model fields
84
+ - Auto-wire middleware from config flags
85
+ - Route enum, event hooks, email hooks
86
+ - `current_user`, `current_active_verified_user`, `require_role` dependencies
87
+ - 97 tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-fullauth
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Production-grade, async-native authentication and authorization library for FastAPI
5
5
  Project-URL: Homepage, https://github.com/mdfarhankc/fastapi-fullauth
6
6
  Project-URL: Documentation, https://github.com/mdfarhankc/fastapi-fullauth
@@ -25,29 +25,12 @@ Requires-Dist: fastapi>=0.110
25
25
  Requires-Dist: pydantic-settings>=2.0
26
26
  Requires-Dist: pydantic[email]>=2.0
27
27
  Requires-Dist: pyjwt>=2.8
28
- Requires-Dist: python-multipart>=0.0.22
29
28
  Requires-Dist: uuid-utils>=0.14.1
30
29
  Provides-Extra: all
31
30
  Requires-Dist: alembic>=1.13; extra == 'all'
32
- Requires-Dist: beanie>=1.25; extra == 'all'
33
- Requires-Dist: httpx-oauth>=0.13; extra == 'all'
34
- Requires-Dist: itsdangerous>=2.1; extra == 'all'
35
- Requires-Dist: pyotp>=2.9; extra == 'all'
36
- Requires-Dist: qrcode>=7.4; extra == 'all'
37
31
  Requires-Dist: redis>=5.0; extra == 'all'
38
32
  Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'all'
39
33
  Requires-Dist: sqlmodel>=0.0.16; extra == 'all'
40
- Requires-Dist: tortoise-orm>=0.21; extra == 'all'
41
- Requires-Dist: webauthn>=2.0; extra == 'all'
42
- Provides-Extra: audit
43
- Provides-Extra: beanie
44
- Requires-Dist: beanie>=1.25; extra == 'beanie'
45
- Provides-Extra: magic-link
46
- Requires-Dist: itsdangerous>=2.1; extra == 'magic-link'
47
- Provides-Extra: oauth
48
- Requires-Dist: httpx-oauth>=0.13; extra == 'oauth'
49
- Provides-Extra: passkeys
50
- Requires-Dist: webauthn>=2.0; extra == 'passkeys'
51
34
  Provides-Extra: redis
52
35
  Requires-Dist: redis>=5.0; extra == 'redis'
53
36
  Provides-Extra: sqlalchemy
@@ -56,12 +39,6 @@ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'sqlalchemy'
56
39
  Provides-Extra: sqlmodel
57
40
  Requires-Dist: alembic>=1.13; extra == 'sqlmodel'
58
41
  Requires-Dist: sqlmodel>=0.0.16; extra == 'sqlmodel'
59
- Provides-Extra: tenants
60
- Provides-Extra: tortoise
61
- Requires-Dist: tortoise-orm>=0.21; extra == 'tortoise'
62
- Provides-Extra: totp
63
- Requires-Dist: pyotp>=2.9; extra == 'totp'
64
- Requires-Dist: qrcode>=7.4; extra == 'totp'
65
42
  Description-Content-Type: text/markdown
66
43
 
67
44
  # fastapi-fullauth
@@ -131,16 +108,31 @@ No need to create separate schema classes or subclass the adapter. Registration
131
108
 
132
109
  ## Protected routes
133
110
 
111
+ Use the `Annotated` types for clean route signatures:
112
+
134
113
  ```python
135
- from fastapi import Depends
136
- from fastapi_fullauth.dependencies import current_user, require_role
114
+ from fastapi_fullauth.dependencies import CurrentUser, VerifiedUser, SuperUser, require_role
137
115
 
138
116
  @app.get("/profile")
139
- async def profile(user=Depends(current_user)):
117
+ async def profile(user: CurrentUser):
140
118
  return user
141
119
 
120
+ @app.get("/dashboard")
121
+ async def dashboard(user: VerifiedUser):
122
+ # only email-verified users
123
+ return {"email": user.email}
124
+
142
125
  @app.delete("/admin/users/{id}")
143
- async def delete_user(user=Depends(require_role("admin"))):
126
+ async def delete_user(user: SuperUser):
127
+ # only superusers
128
+ ...
129
+
130
+ # or use require_role for custom roles
131
+ from fastapi import Depends
132
+ from fastapi_fullauth.dependencies import require_role
133
+
134
+ @app.get("/editor")
135
+ async def editor_panel(user=Depends(require_role("editor"))):
144
136
  ...
145
137
  ```
146
138
 
@@ -190,15 +182,44 @@ fullauth.hooks.on("after_register", welcome)
190
182
 
191
183
  Events: `after_register`, `after_login`, `after_logout`, `after_password_change`, `after_password_reset`, `after_email_verify`, `send_verification_email`, `send_password_reset_email`
192
184
 
193
- ## Route control
185
+ ## Custom token claims
186
+
187
+ Embed app-specific data into JWTs (available in `payload.extra`):
194
188
 
195
189
  ```python
196
- from fastapi_fullauth import Route
190
+ async def add_claims(user):
191
+ return {"tenant_id": "acme", "plan": "pro"}
192
+
193
+ fullauth = FullAuth(
194
+ secret_key="...",
195
+ adapter=adapter,
196
+ on_create_token_claims=add_claims,
197
+ )
198
+ ```
197
199
 
200
+ Reserved keys (`sub`, `exp`, `iat`, `jti`, `type`, `roles`, `extra`, `family_id`) are rejected to prevent accidental overwrites.
201
+
202
+ ## Password hashing
203
+
204
+ Argon2id by default. Switch to bcrypt via config:
205
+
206
+ ```python
207
+ fullauth = FullAuth(
208
+ secret_key="...",
209
+ adapter=adapter,
210
+ password_hash_algorithm="bcrypt", # requires: pip install bcrypt
211
+ )
212
+ ```
213
+
214
+ When switching algorithms, existing users are transparently rehashed on their next login.
215
+
216
+ ## Route control
217
+
218
+ ```python
198
219
  fullauth = FullAuth(
199
220
  secret_key="...",
200
221
  adapter=adapter,
201
- enabled_routes=[Route.LOGIN, Route.LOGOUT, Route.REFRESH],
222
+ enabled_routes=["login", "logout", "refresh"],
202
223
  )
203
224
  ```
204
225
 
@@ -65,16 +65,31 @@ No need to create separate schema classes or subclass the adapter. Registration
65
65
 
66
66
  ## Protected routes
67
67
 
68
+ Use the `Annotated` types for clean route signatures:
69
+
68
70
  ```python
69
- from fastapi import Depends
70
- from fastapi_fullauth.dependencies import current_user, require_role
71
+ from fastapi_fullauth.dependencies import CurrentUser, VerifiedUser, SuperUser, require_role
71
72
 
72
73
  @app.get("/profile")
73
- async def profile(user=Depends(current_user)):
74
+ async def profile(user: CurrentUser):
74
75
  return user
75
76
 
77
+ @app.get("/dashboard")
78
+ async def dashboard(user: VerifiedUser):
79
+ # only email-verified users
80
+ return {"email": user.email}
81
+
76
82
  @app.delete("/admin/users/{id}")
77
- async def delete_user(user=Depends(require_role("admin"))):
83
+ async def delete_user(user: SuperUser):
84
+ # only superusers
85
+ ...
86
+
87
+ # or use require_role for custom roles
88
+ from fastapi import Depends
89
+ from fastapi_fullauth.dependencies import require_role
90
+
91
+ @app.get("/editor")
92
+ async def editor_panel(user=Depends(require_role("editor"))):
78
93
  ...
79
94
  ```
80
95
 
@@ -124,15 +139,44 @@ fullauth.hooks.on("after_register", welcome)
124
139
 
125
140
  Events: `after_register`, `after_login`, `after_logout`, `after_password_change`, `after_password_reset`, `after_email_verify`, `send_verification_email`, `send_password_reset_email`
126
141
 
127
- ## Route control
142
+ ## Custom token claims
143
+
144
+ Embed app-specific data into JWTs (available in `payload.extra`):
128
145
 
129
146
  ```python
130
- from fastapi_fullauth import Route
147
+ async def add_claims(user):
148
+ return {"tenant_id": "acme", "plan": "pro"}
149
+
150
+ fullauth = FullAuth(
151
+ secret_key="...",
152
+ adapter=adapter,
153
+ on_create_token_claims=add_claims,
154
+ )
155
+ ```
131
156
 
157
+ Reserved keys (`sub`, `exp`, `iat`, `jti`, `type`, `roles`, `extra`, `family_id`) are rejected to prevent accidental overwrites.
158
+
159
+ ## Password hashing
160
+
161
+ Argon2id by default. Switch to bcrypt via config:
162
+
163
+ ```python
164
+ fullauth = FullAuth(
165
+ secret_key="...",
166
+ adapter=adapter,
167
+ password_hash_algorithm="bcrypt", # requires: pip install bcrypt
168
+ )
169
+ ```
170
+
171
+ When switching algorithms, existing users are transparently rehashed on their next login.
172
+
173
+ ## Route control
174
+
175
+ ```python
132
176
  fullauth = FullAuth(
133
177
  secret_key="...",
134
178
  adapter=adapter,
135
- enabled_routes=[Route.LOGIN, Route.LOGOUT, Route.REFRESH],
179
+ enabled_routes=["login", "logout", "refresh"],
136
180
  )
137
181
  ```
138
182
 
@@ -21,8 +21,8 @@ async def add_custom_claims(user: UserSchema) -> dict:
21
21
  fullauth = FullAuth(
22
22
  secret_key="change-me-use-a-32-byte-key-here",
23
23
  adapter=SQLModelAdapter(session_maker=session_maker, user_model=User),
24
- on_send_verification_email=send_verification_email,
25
- on_send_password_reset_email=send_password_reset_email,
26
24
  on_create_token_claims=add_custom_claims,
27
25
  include_user_in_login=True,
28
26
  )
27
+ fullauth.hooks.on("send_verification_email", send_verification_email)
28
+ fullauth.hooks.on("send_password_reset_email", send_password_reset_email)
@@ -1,8 +1,8 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.0"
2
2
 
3
3
  from fastapi_fullauth.config import FullAuthConfig
4
4
  from fastapi_fullauth.fullauth import FullAuth
5
- from fastapi_fullauth.types import Route
5
+ from fastapi_fullauth.types import RouteName
6
6
  from fastapi_fullauth.utils import create_superuser, generate_secret_key
7
7
  from fastapi_fullauth.validators import PasswordValidator
8
8
 
@@ -10,7 +10,7 @@ __all__ = [
10
10
  "FullAuth",
11
11
  "FullAuthConfig",
12
12
  "PasswordValidator",
13
- "Route",
13
+ "RouteName",
14
14
  "create_superuser",
15
15
  "generate_secret_key",
16
16
  ]
@@ -55,6 +55,9 @@ class AbstractUserAdapter(ABC):
55
55
  @abstractmethod
56
56
  async def revoke_refresh_token_family(self, family_id: str) -> None: ...
57
57
 
58
+ @abstractmethod
59
+ async def revoke_all_user_refresh_tokens(self, user_id: str) -> None: ...
60
+
58
61
  @abstractmethod
59
62
  async def set_user_verified(self, user_id: str) -> None: ...
60
63
 
@@ -82,6 +82,11 @@ class InMemoryAdapter(AbstractUserAdapter):
82
82
  if tok.family_id == family_id:
83
83
  tok.revoked = True
84
84
 
85
+ async def revoke_all_user_refresh_tokens(self, user_id: str) -> None:
86
+ for tok in self._refresh_tokens.values():
87
+ if tok.user_id == user_id:
88
+ tok.revoked = True
89
+
85
90
  async def set_user_verified(self, user_id: str) -> None:
86
91
  if user_id in self._users:
87
92
  self._users[user_id]["is_verified"] = True
@@ -203,6 +203,15 @@ class SQLAlchemyAdapter(AbstractUserAdapter):
203
203
  )
204
204
  await session.commit()
205
205
 
206
+ async def revoke_all_user_refresh_tokens(self, user_id: str) -> None:
207
+ async with self._session_maker() as session:
208
+ await session.execute(
209
+ update(RefreshTokenModel)
210
+ .where(RefreshTokenModel.user_id == user_id)
211
+ .values(revoked=True)
212
+ )
213
+ await session.commit()
214
+
206
215
  async def set_user_verified(self, user_id: str) -> None:
207
216
  async with self._session_maker() as session:
208
217
  await session.execute(
@@ -180,6 +180,15 @@ class SQLModelAdapter(AbstractUserAdapter):
180
180
  )
181
181
  await session.commit()
182
182
 
183
+ async def revoke_all_user_refresh_tokens(self, user_id: str) -> None:
184
+ async with self._session_maker() as session:
185
+ await session.execute(
186
+ update(RefreshTokenRecord)
187
+ .where(RefreshTokenRecord.user_id == user_id)
188
+ .values(revoked=True)
189
+ )
190
+ await session.commit()
191
+
183
192
  async def set_user_verified(self, user_id: str) -> None:
184
193
  async with self._session_maker() as session:
185
194
  await session.execute(
@@ -0,0 +1,47 @@
1
+ from typing import Literal
2
+
3
+ from argon2 import PasswordHasher
4
+ from argon2.exceptions import VerificationError, VerifyMismatchError
5
+
6
+ _argon2_hasher = PasswordHasher()
7
+ _algorithm: Literal["argon2id", "bcrypt"] = "argon2id"
8
+
9
+
10
+ def configure_hasher(algorithm: Literal["argon2id", "bcrypt"] = "argon2id") -> None:
11
+ global _algorithm
12
+ if algorithm == "bcrypt":
13
+ try:
14
+ import bcrypt # noqa: F401
15
+ except ImportError:
16
+ raise ImportError(
17
+ "bcrypt is not installed. Install it with: pip install bcrypt"
18
+ ) from None
19
+ _algorithm = algorithm
20
+
21
+
22
+ def hash_password(password: str) -> str:
23
+ if _algorithm == "bcrypt":
24
+ import bcrypt
25
+
26
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
27
+ return _argon2_hasher.hash(password)
28
+
29
+
30
+ def verify_password(plain: str, hashed: str) -> bool:
31
+ if _algorithm == "bcrypt" or hashed.startswith("$2b$"):
32
+ try:
33
+ import bcrypt
34
+
35
+ return bcrypt.checkpw(plain.encode(), hashed.encode())
36
+ except ImportError:
37
+ return False
38
+ try:
39
+ return _argon2_hasher.verify(hashed, plain)
40
+ except (VerifyMismatchError, VerificationError):
41
+ return False
42
+
43
+
44
+ def password_needs_rehash(hashed: str) -> bool:
45
+ if hashed.startswith("$2b$"):
46
+ return _algorithm != "bcrypt"
47
+ return _argon2_hasher.check_needs_rehash(hashed)
@@ -1,3 +1,4 @@
1
+ import time
1
2
  import uuid
2
3
  from datetime import datetime, timedelta, timezone
3
4
 
@@ -5,7 +6,7 @@ import jwt
5
6
 
6
7
  from fastapi_fullauth.config import FullAuthConfig
7
8
  from fastapi_fullauth.exceptions import TokenBlacklistedError, TokenError, TokenExpiredError
8
- from fastapi_fullauth.types import TokenPayload
9
+ from fastapi_fullauth.types import RefreshTokenMeta, TokenPayload
9
10
 
10
11
 
11
12
  class TokenBlacklist:
@@ -18,13 +19,20 @@ class TokenBlacklist:
18
19
 
19
20
  class InMemoryBlacklist(TokenBlacklist):
20
21
  def __init__(self) -> None:
21
- self._blacklisted: set[str] = set()
22
+ self._blacklisted: dict[str, float | None] = {}
22
23
 
23
24
  async def add(self, jti: str, ttl_seconds: int | None = None) -> None:
24
- self._blacklisted.add(jti)
25
+ expires_at = (time.monotonic() + ttl_seconds) if ttl_seconds else None
26
+ self._blacklisted[jti] = expires_at
25
27
 
26
28
  async def is_blacklisted(self, jti: str) -> bool:
27
- return jti in self._blacklisted
29
+ expires_at = self._blacklisted.get(jti)
30
+ if expires_at is None and jti not in self._blacklisted:
31
+ return False
32
+ if expires_at is not None and time.monotonic() > expires_at:
33
+ del self._blacklisted[jti]
34
+ return False
35
+ return True
28
36
 
29
37
 
30
38
  class TokenEngine:
@@ -54,17 +62,20 @@ class TokenEngine:
54
62
  self,
55
63
  user_id: str,
56
64
  family_id: str | None = None,
57
- ) -> str:
65
+ ) -> RefreshTokenMeta:
58
66
  now = datetime.now(timezone.utc)
67
+ expires_at = now + timedelta(days=self.config.REFRESH_TOKEN_EXPIRE_DAYS)
68
+ resolved_family_id = family_id or uuid.uuid4().hex
59
69
  payload = {
60
70
  "sub": user_id,
61
- "exp": now + timedelta(days=self.config.REFRESH_TOKEN_EXPIRE_DAYS),
71
+ "exp": expires_at,
62
72
  "iat": now,
63
73
  "jti": uuid.uuid4().hex,
64
74
  "type": "refresh",
65
- "family_id": family_id or uuid.uuid4().hex,
75
+ "family_id": resolved_family_id,
66
76
  }
67
- return jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
77
+ token = jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
78
+ return RefreshTokenMeta(token=token, expires_at=expires_at, family_id=resolved_family_id)
68
79
 
69
80
  async def decode_token(self, token: str) -> TokenPayload:
70
81
  try:
@@ -98,7 +109,7 @@ class TokenEngine:
98
109
  roles: list[str] | None = None,
99
110
  extra: dict | None = None,
100
111
  family_id: str | None = None,
101
- ) -> tuple[str, str]:
112
+ ) -> tuple[str, RefreshTokenMeta]:
102
113
  access = self.create_access_token(user_id, roles, extra)
103
114
  refresh = self.create_refresh_token(user_id, family_id)
104
115
  return access, refresh
@@ -1,21 +1,20 @@
1
- from typing import Annotated
2
-
3
- from fastapi import Depends
4
-
5
1
  from fastapi_fullauth.dependencies.current_user import (
6
2
  current_active_verified_user,
3
+ current_superuser,
7
4
  current_user,
5
+ CurrentUser,
6
+ VerifiedUser,
7
+ SuperUser,
8
8
  )
9
9
  from fastapi_fullauth.dependencies.require_role import require_permission, require_role
10
- from fastapi_fullauth.types import UserSchema
11
10
 
12
- CurrentUser = Annotated[UserSchema, Depends(current_user)]
13
- VerifiedUser = Annotated[UserSchema, Depends(current_active_verified_user)]
14
11
 
15
12
  __all__ = [
16
13
  "CurrentUser",
17
14
  "VerifiedUser",
15
+ "SuperUser",
18
16
  "current_active_verified_user",
17
+ "current_superuser",
19
18
  "current_user",
20
19
  "require_permission",
21
20
  "require_role",
@@ -1,4 +1,4 @@
1
- from typing import TYPE_CHECKING
1
+ from typing import TYPE_CHECKING, Annotated
2
2
 
3
3
  from fastapi import Depends, Request
4
4
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -6,6 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
6
  from fastapi_fullauth.exceptions import CREDENTIALS_EXCEPTION
7
7
  from fastapi_fullauth.types import UserSchema
8
8
 
9
+
9
10
  if TYPE_CHECKING:
10
11
  from fastapi_fullauth.fullauth import FullAuth
11
12
 
@@ -14,7 +15,8 @@ _bearer_scheme = HTTPBearer(auto_error=False)
14
15
 
15
16
 
16
17
  def _get_fullauth(request: Request) -> "FullAuth":
17
- fullauth: FullAuth | None = request.app.state.fullauth # type: ignore[union-attr]
18
+ # type: ignore[union-attr]
19
+ fullauth: FullAuth | None = request.app.state.fullauth
18
20
  if fullauth is None:
19
21
  raise RuntimeError("FullAuth not initialized on app.state")
20
22
  return fullauth
@@ -36,7 +38,6 @@ async def _extract_token(
36
38
 
37
39
 
38
40
  async def current_user(
39
- request: Request,
40
41
  fullauth: "FullAuth" = Depends(_get_fullauth),
41
42
  token: str = Depends(_extract_token),
42
43
  ) -> UserSchema:
@@ -50,6 +51,9 @@ async def current_user(
50
51
  if payload.type != "access":
51
52
  raise CREDENTIALS_EXCEPTION
52
53
 
54
+ if payload.extra.get("purpose"):
55
+ raise CREDENTIALS_EXCEPTION
56
+
53
57
  user = await fullauth.adapter.get_user_by_id(payload.sub)
54
58
  if user is None or not user.is_active:
55
59
  raise CREDENTIALS_EXCEPTION
@@ -57,22 +61,28 @@ async def current_user(
57
61
  return user
58
62
 
59
63
 
64
+ CurrentUser = Annotated[UserSchema, Depends(current_user)]
65
+
66
+
60
67
  async def current_active_verified_user(
61
- request: Request,
62
- fullauth: "FullAuth" = Depends(_get_fullauth),
63
- token: str = Depends(_extract_token),
68
+ user: CurrentUser,
64
69
  ) -> UserSchema:
65
- from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION, TokenError
66
-
67
- try:
68
- payload = await fullauth.token_engine.decode_token(token)
69
- except TokenError:
70
- raise CREDENTIALS_EXCEPTION
70
+ from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
71
71
 
72
- user = await fullauth.adapter.get_user_by_id(payload.sub)
73
- if user is None or not user.is_active:
74
- raise CREDENTIALS_EXCEPTION
75
72
  if not user.is_verified:
76
73
  raise FORBIDDEN_EXCEPTION
74
+ return user
77
75
 
76
+
77
+ async def current_superuser(
78
+ user: CurrentUser,
79
+ ) -> UserSchema:
80
+ from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
81
+
82
+ if not user.is_superuser:
83
+ raise FORBIDDEN_EXCEPTION
78
84
  return user
85
+
86
+
87
+ VerifiedUser = Annotated[UserSchema, Depends(current_active_verified_user)]
88
+ SuperUser = Annotated[UserSchema, Depends(current_superuser)]
@@ -0,0 +1,33 @@
1
+ from fastapi import Depends
2
+
3
+ from fastapi_fullauth.dependencies.current_user import current_user
4
+ from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
5
+ from fastapi_fullauth.types import UserSchema
6
+
7
+
8
+ def require_role(*roles: str):
9
+ """Dependency that checks the user has at least one of the given roles."""
10
+
11
+ async def _dep(
12
+ user: UserSchema = Depends(current_user),
13
+ ) -> UserSchema:
14
+ if user.is_superuser:
15
+ return user
16
+
17
+ user_roles = set(user.roles)
18
+ if not user_roles.intersection(roles):
19
+ raise FORBIDDEN_EXCEPTION
20
+
21
+ return user
22
+
23
+ return _dep
24
+
25
+
26
+ def require_permission(*permissions: str):
27
+ """Dependency that checks the user has at least one of the given permissions.
28
+
29
+ Permissions use the format 'resource:action' (e.g. 'posts:delete').
30
+ When a full permission engine is added, this will resolve permissions
31
+ against it. For now, permissions map 1:1 to roles.
32
+ """
33
+ return require_role(*permissions)