fastapi-fullauth 0.17.0__tar.gz → 0.17.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/CHANGELOG.md +18 -0
  2. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/PKG-INFO +1 -1
  3. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/configuration.md +5 -0
  4. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/__init__.py +1 -1
  5. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/_sqlalchemy_base.py +128 -72
  6. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/config.py +22 -13
  7. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/blacklist.py +3 -3
  8. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/challenges.py +8 -1
  9. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/lockout.py +1 -1
  10. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/ratelimit.py +16 -2
  11. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/pyproject.toml +6 -0
  12. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/conftest.py +15 -3
  13. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_config.py +48 -0
  14. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_passkey.py +22 -0
  15. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_security.py +56 -0
  16. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_sqlmodel_adapter.py +21 -1
  17. fastapi_fullauth-0.17.2/uv.lock +2772 -0
  18. fastapi_fullauth-0.17.0/uv.lock +0 -2658
  19. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  20. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/pull_request_template.md +0 -0
  22. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/workflows/ci.yml +0 -0
  23. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/workflows/docs.yml +0 -0
  24. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/workflows/publish.yml +0 -0
  25. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.gitignore +0 -0
  26. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.python-version +0 -0
  27. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/AGENTS.md +0 -0
  28. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/CONTRIBUTING.md +0 -0
  29. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/LICENSE +0 -0
  30. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/Makefile +0 -0
  31. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/README.md +0 -0
  32. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/SECURITY.md +0 -0
  33. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/beanie.md +0 -0
  34. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/custom.md +0 -0
  35. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/index.md +0 -0
  36. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/sqlalchemy.md +0 -0
  37. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/sqlmodel.md +0 -0
  38. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/tortoise.md +0 -0
  39. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/api-reference.md +0 -0
  40. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/architecture.md +0 -0
  41. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/custom-claims.md +0 -0
  42. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/dependencies.md +0 -0
  43. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/hooks.md +0 -0
  44. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/passwords.md +0 -0
  45. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/sessions.md +0 -0
  46. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/contributing.md +0 -0
  47. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/customization.md +0 -0
  48. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/frontend-integration.md +0 -0
  49. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/getting-started.md +0 -0
  50. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/index.md +0 -0
  51. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/migrating-from-fastapi-users.md +0 -0
  52. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/migrations.md +0 -0
  53. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/oauth.md +0 -0
  54. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/passkeys.md +0 -0
  55. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/recipes.md +0 -0
  56. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/security/middleware.md +0 -0
  57. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/security/rate-limiting.md +0 -0
  58. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/security/threat-model.md +0 -0
  59. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/stylesheets/home.css +0 -0
  60. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/testing.md +0 -0
  61. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/troubleshooting.md +0 -0
  62. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/__init__.py +0 -0
  63. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/auth.py +0 -0
  64. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/config.py +0 -0
  65. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/main.py +0 -0
  66. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/models.py +0 -0
  67. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/routes.py +0 -0
  68. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/__init__.py +0 -0
  69. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/auth.py +0 -0
  70. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/config.py +0 -0
  71. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/main.py +0 -0
  72. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/models.py +0 -0
  73. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/routes.py +0 -0
  74. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/__init__.py +0 -0
  75. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/auth.py +0 -0
  76. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/config.py +0 -0
  77. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/main.py +0 -0
  78. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/models.py +0 -0
  79. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/routes.py +0 -0
  80. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/__init__.py +0 -0
  81. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/auth.py +0 -0
  82. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/config.py +0 -0
  83. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/main.py +0 -0
  84. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/models.py +0 -0
  85. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/routes.py +0 -0
  86. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/__init__.py +0 -0
  87. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/base.py +0 -0
  88. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/beanie.py +0 -0
  89. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/sqlalchemy.py +0 -0
  90. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/sqlmodel.py +0 -0
  91. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/tortoise.py +0 -0
  92. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/__init__.py +0 -0
  93. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/base.py +0 -0
  94. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/bearer.py +0 -0
  95. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/cookie.py +0 -0
  96. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/cli.py +0 -0
  97. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/__init__.py +0 -0
  98. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/_redis.py +0 -0
  99. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/crypto.py +0 -0
  100. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/tokens.py +0 -0
  101. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/dependencies/__init__.py +0 -0
  102. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/dependencies/current_user.py +0 -0
  103. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/dependencies/rbac.py +0 -0
  104. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/exceptions.py +0 -0
  105. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/__init__.py +0 -0
  106. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/change_password.py +0 -0
  107. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/credentials.py +0 -0
  108. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/email_verify.py +0 -0
  109. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/login.py +0 -0
  110. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/logout.py +0 -0
  111. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/oauth.py +0 -0
  112. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/passkey.py +0 -0
  113. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/password_reset.py +0 -0
  114. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/profile.py +0 -0
  115. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/reauth.py +0 -0
  116. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/refresh.py +0 -0
  117. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/register.py +0 -0
  118. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/sessions.py +0 -0
  119. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/tokens.py +0 -0
  120. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/fullauth.py +0 -0
  121. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/hooks.py +0 -0
  122. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/__init__.py +0 -0
  123. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/csrf.py +0 -0
  124. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/ratelimit.py +0 -0
  125. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/security_headers.py +0 -0
  126. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/__init__.py +0 -0
  127. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/__init__.py +0 -0
  128. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/_common.py +0 -0
  129. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/base.py +0 -0
  130. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/oauth.py +0 -0
  131. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/passkey.py +0 -0
  132. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/permission.py +0 -0
  133. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/role.py +0 -0
  134. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/__init__.py +0 -0
  135. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/base.py +0 -0
  136. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/oauth.py +0 -0
  137. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/passkey.py +0 -0
  138. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/permission.py +0 -0
  139. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/role.py +0 -0
  140. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/__init__.py +0 -0
  141. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/base.py +0 -0
  142. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/oauth.py +0 -0
  143. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/passkey.py +0 -0
  144. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/permission.py +0 -0
  145. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/role.py +0 -0
  146. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/__init__.py +0 -0
  147. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/_common.py +0 -0
  148. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/base.py +0 -0
  149. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/oauth.py +0 -0
  150. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/passkey.py +0 -0
  151. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/permission.py +0 -0
  152. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/role.py +0 -0
  153. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/__init__.py +0 -0
  154. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/base.py +0 -0
  155. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/discord.py +0 -0
  156. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/github.py +0 -0
  157. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/gitlab.py +0 -0
  158. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/google.py +0 -0
  159. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/__init__.py +0 -0
  160. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/py.typed +0 -0
  161. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/__init__.py +0 -0
  162. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/_schemas.py +0 -0
  163. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/_transport.py +0 -0
  164. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/admin.py +0 -0
  165. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/auth.py +0 -0
  166. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/oauth.py +0 -0
  167. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/passkey.py +0 -0
  168. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/profile.py +0 -0
  169. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/sessions.py +0 -0
  170. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/verify.py +0 -0
  171. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/types.py +0 -0
  172. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/utils.py +0 -0
  173. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/validators.py +0 -0
  174. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/mkdocs.yml +0 -0
  175. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/scripts/llmstxt_preprocess.py +0 -0
  176. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/SKILL.md +0 -0
  177. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/adapters.md +0 -0
  178. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/api-reference.md +0 -0
  179. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/composable-design.md +0 -0
  180. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/getting-started.md +0 -0
  181. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/hooks.md +0 -0
  182. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/migrations.md +0 -0
  183. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/oauth.md +0 -0
  184. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/passkeys.md +0 -0
  185. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/production.md +0 -0
  186. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/rbac.md +0 -0
  187. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/sessions.md +0 -0
  188. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/testing.md +0 -0
  189. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/troubleshooting.md +0 -0
  190. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/__init__.py +0 -0
  191. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/adapter_conformance.py +0 -0
  192. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_account_safety.py +0 -0
  193. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_auth.py +0 -0
  194. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_beanie_adapter.py +0 -0
  195. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_cli.py +0 -0
  196. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_cookie_backend.py +0 -0
  197. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_crypto.py +0 -0
  198. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_hooks.py +0 -0
  199. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_oauth.py +0 -0
  200. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_passkey_flows.py +0 -0
  201. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_polish.py +0 -0
  202. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_profile.py +0 -0
  203. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_rbac.py +0 -0
  204. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_session_revocation.py +0 -0
  205. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_sessions.py +0 -0
  206. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_sqlalchemy_adapter.py +0 -0
  207. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_tokens.py +0 -0
  208. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_tortoise_adapter.py +0 -0
  209. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_tortoise_int_keys.py +0 -0
  210. {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_user_id_types.py +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.2
4
+
5
+ ### Fixed
6
+
7
+ - **The Redis challenge store raised on a non-positive TTL** where the in-memory one wrote an entry that was simply already expired. With `PASSKEY_CHALLENGE_TTL` set to `0`, beginning a passkey sign-in answered `500` on Redis, on a route that needs no account. Both backends now agree: nothing is stored and the next `pop()` returns `None`.
8
+ - **Numeric settings reject values that cannot work.** Durations, expiries, attempt counts and rate-limit caps are validated at construction, so a mistake fails at startup instead of somewhere unrelated later: `MAX_LOGIN_ATTEMPTS=0` locked every account on its first failed login, and `PASSKEY_CHALLENGE_TTL=0` raised from Redis mid-request. The three settings that document a meaningful zero keep it: `JWT_LEEWAY_SECONDS`, `PASSWORD_MAX_LENGTH` and `REAUTH_MAX_AGE_SECONDS`. **If your configuration holds one of these impossible values, the application now refuses to start and says which setting.**
9
+ - **Auth rate limits shared one bucket per IP on Redis.** Each route gets its own limiter with its own cap, but the key was the client IP alone, and every Redis-backed limiter writes into the same keyspace, so all six auth routes counted into a single sorted set. The smallest cap won for all of them: with `register` at 3, any three auth requests, a token refresh or a failed login included, returned `429` on the next registration attempt for the rest of the window. It also broke the separation `reauth` was given in 0.17.0, where failed password checks were meant not to touch the sign-in budget. The key now carries the route name. Only Redis was affected; the in-memory backend keeps its counts per limiter instance, which is why the test suite never saw it, and why the new regression test runs against both backends. **Note:** the key format changed, so existing counters reset once on deploy.
10
+
11
+ ## 0.17.1
12
+
13
+ ### Changed
14
+
15
+ - **The SQL adapters no longer call an API that SQLModel deprecates.** `_BaseSQLAlchemyAdapter` is shared by the SQLAlchemy and SQLModel adapters, and its 40 `session.execute()` calls emitted a `DeprecationWarning` for anyone using SQLModel's `AsyncSession`, which is one of the two setups the documentation offers. Applications that treat warnings as errors failed on it. `exec()` cannot replace it, because a plain SQLAlchemy session does not have one and the base serves both; the calls now go through a helper that invokes SQLAlchemy's own `execute()` directly. SQLModel's override does nothing but delegate to it, so behaviour is identical and results are still `Row` objects.
16
+ - **Redis writes use `set(..., ex=)` instead of `setex`,** which redis-py has deprecated since 2.6.12. Affects the token blacklist, the passkey challenge store, and the lockout manager. Stored values and expiries are unchanged.
17
+ - **The SQLModel adapter contract runs against both documented session classes.** Every test built its sessions with SQLAlchemy's `AsyncSession`, so the SQLModel one had no coverage at all, which is why the deprecation above reached an application instead of CI. The conformance suite now runs a second time with `class_=AsyncSession` from SQLModel.
18
+ - **The test suite treats deprecations as errors,** with no ignore entries. A deprecation that cannot be fixed yet gets its own line naming what it waits for, rather than a blanket suppression.
19
+ - **Dependencies refreshed and verified against the newest releases,** including uuid-utils 1.0, SQLModel 0.0.47, SQLAlchemy 2.0.54, PyJWT 2.15, Starlette 1.7 and pymdown-extensions 12. The declared version floors are unchanged: they state the oldest release the library works with, not the newest it was tested against, and nothing here requires a newer one.
20
+
3
21
  ## 0.17.0
4
22
 
5
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastapi-fullauth
3
- Version: 0.17.0
3
+ Version: 0.17.2
4
4
  Summary: Production-grade, async-native authentication and authorization library for FastAPI
5
5
  Project-URL: Changelog, https://github.com/mdfarhankc/fastapi-fullauth/blob/main/CHANGELOG.md
6
6
  Project-URL: Documentation, https://mdfarhankc.github.io/fastapi-fullauth
@@ -124,6 +124,11 @@ If you want to be defensively explicit that no file is ever read, pass `FullAuth
124
124
 
125
125
  ## Reference
126
126
 
127
+ Durations, expiries, attempt counts and rate-limit caps must be at least 1, and a
128
+ value that cannot work is rejected when the config is constructed rather than
129
+ failing somewhere unrelated later. `JWT_LEEWAY_SECONDS`, `PASSWORD_MAX_LENGTH`
130
+ and `REAUTH_MAX_AGE_SECONDS` accept `0`, which means something for each of them.
131
+
127
132
  ### Core
128
133
 
129
134
  | Option | Type | Default | Description |
@@ -1,4 +1,4 @@
1
- __version__ = "0.17.0"
1
+ __version__ = "0.17.2"
2
2
 
3
3
  from fastapi_fullauth.config import AuthRateLimits, FullAuthConfig
4
4
  from fastapi_fullauth.fullauth import FullAuth
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
16
16
  from typing import Any, TypeVar, cast
17
17
  from uuid import UUID
18
18
 
19
- from sqlalchemy import CursorResult, delete, select, update
19
+ from sqlalchemy import CursorResult, Result, delete, select, update
20
20
  from sqlalchemy.exc import IntegrityError
21
21
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
22
22
  from sqlalchemy.orm import selectinload
@@ -258,6 +258,22 @@ class _BaseSQLAlchemyAdapter(
258
258
  await session.rollback()
259
259
  raise
260
260
 
261
+ @staticmethod
262
+ async def _execute(session: AsyncSession, statement: Any) -> Result[Any]:
263
+ """Run a statement through SQLAlchemy's own ``execute()``.
264
+
265
+ SQLModel's ``AsyncSession`` deprecates ``execute()`` in favour of
266
+ ``exec()``, but ``exec()`` does not exist on a plain SQLAlchemy session
267
+ and this base serves both. Its override only delegates to the parent, so
268
+ calling the parent directly is identical and warns for nobody. Results
269
+ stay ``Row`` objects, hence the ``.scalars()`` calls throughout.
270
+
271
+ SQLAlchemy infers the row type from the statement at the call site; it
272
+ cannot do that through a helper, so a caller that needs the column type
273
+ narrows it itself.
274
+ """
275
+ return await AsyncSession.execute(session, statement)
276
+
261
277
  def _user_query(self) -> Any:
262
278
  # Eager-load roles so _to_schema() can read user.roles outside the session
263
279
  # without triggering an async refresh.
@@ -278,7 +294,9 @@ class _BaseSQLAlchemyAdapter(
278
294
 
279
295
  async def get_user_by_id(self, user_id: UserID) -> UserSchemaType | None:
280
296
  async with self._begin() as session:
281
- result = await session.execute(self._user_query().where(self._user_model.id == user_id))
297
+ result = await self._execute(
298
+ session, self._user_query().where(self._user_model.id == user_id)
299
+ )
282
300
  user = result.scalars().first()
283
301
  return self._to_schema(user) if user else None
284
302
 
@@ -294,7 +312,7 @@ class _BaseSQLAlchemyAdapter(
294
312
  if field == "email":
295
313
  value = normalize_email(value)
296
314
  async with self._begin() as session:
297
- result = await session.execute(self._user_query().where(column == value))
315
+ result = await self._execute(session, self._user_query().where(column == value))
298
316
  user = result.scalars().first()
299
317
  return self._to_schema(user) if user else None
300
318
 
@@ -316,7 +334,9 @@ class _BaseSQLAlchemyAdapter(
316
334
  f"User with email {normalized_email} already exists"
317
335
  ) from e
318
336
  # Re-fetch with roles eager-loaded so _to_schema works outside the session.
319
- result = await session.execute(self._user_query().where(self._user_model.id == user.id))
337
+ result = await self._execute(
338
+ session, self._user_query().where(self._user_model.id == user.id)
339
+ )
320
340
  user = result.scalars().first()
321
341
  assert user is not None
322
342
  await self._commit(session)
@@ -329,11 +349,14 @@ class _BaseSQLAlchemyAdapter(
329
349
  if data:
330
350
  # Skip an empty UPDATE: update(...).values() with no columns is a
331
351
  # compile error in SQLAlchemy. An empty update is a no-op anyway.
332
- await session.execute(
333
- update(self._user_model).where(self._user_model.id == user_id).values(**data)
352
+ await self._execute(
353
+ session,
354
+ update(self._user_model).where(self._user_model.id == user_id).values(**data),
334
355
  )
335
356
  await self._commit(session)
336
- result = await session.execute(self._user_query().where(self._user_model.id == user_id))
357
+ result = await self._execute(
358
+ session, self._user_query().where(self._user_model.id == user_id)
359
+ )
337
360
  user = result.scalars().first()
338
361
  if user is None:
339
362
  raise ValueError(f"User {user_id} not found")
@@ -341,8 +364,8 @@ class _BaseSQLAlchemyAdapter(
341
364
 
342
365
  async def delete_user(self, user_id: UserID) -> None:
343
366
  async with self._begin() as session:
344
- result = await session.execute(
345
- select(self._user_model).where(self._user_model.id == user_id)
367
+ result = await self._execute(
368
+ session, select(self._user_model).where(self._user_model.id == user_id)
346
369
  )
347
370
  user = result.scalars().first()
348
371
  if user:
@@ -353,7 +376,9 @@ class _BaseSQLAlchemyAdapter(
353
376
  if not hasattr(self._user_model, "roles"):
354
377
  return []
355
378
  async with self._begin() as session:
356
- result = await session.execute(self._user_query().where(self._user_model.id == user_id))
379
+ result = await self._execute(
380
+ session, self._user_query().where(self._user_model.id == user_id)
381
+ )
357
382
  user = result.scalars().first()
358
383
  if user is None:
359
384
  return []
@@ -361,17 +386,19 @@ class _BaseSQLAlchemyAdapter(
361
386
 
362
387
  async def get_hashed_password(self, user_id: UserID) -> str | None:
363
388
  async with self._begin() as session:
364
- result = await session.execute(
365
- select(self._user_model.hashed_password).where(self._user_model.id == user_id)
389
+ result = await self._execute(
390
+ session,
391
+ select(self._user_model.hashed_password).where(self._user_model.id == user_id),
366
392
  )
367
- return result.scalars().first()
393
+ return cast("str | None", result.scalars().first())
368
394
 
369
395
  async def set_password(self, user_id: UserID, hashed_password: str) -> None:
370
396
  async with self._begin() as session:
371
- await session.execute(
397
+ await self._execute(
398
+ session,
372
399
  update(self._user_model)
373
400
  .where(self._user_model.id == user_id)
374
- .values(hashed_password=hashed_password)
401
+ .values(hashed_password=hashed_password),
375
402
  )
376
403
  await self._commit(session)
377
404
 
@@ -391,10 +418,11 @@ class _BaseSQLAlchemyAdapter(
391
418
 
392
419
  async def get_refresh_token(self, token_str: str) -> RefreshToken | None:
393
420
  async with self._begin() as session:
394
- result = await session.execute(
421
+ result = await self._execute(
422
+ session,
395
423
  select(self._refresh_token_model).where(
396
424
  self._refresh_token_model.token == token_str
397
- )
425
+ ),
398
426
  )
399
427
  row = result.scalars().first()
400
428
  if row is None:
@@ -413,11 +441,12 @@ class _BaseSQLAlchemyAdapter(
413
441
  async with self._begin() as session:
414
442
  result = cast(
415
443
  "CursorResult[Any]",
416
- await session.execute(
444
+ await self._execute(
445
+ session,
417
446
  update(self._refresh_token_model)
418
447
  .where(self._refresh_token_model.token == token_str)
419
448
  .where(self._refresh_token_model.revoked.is_(False))
420
- .values(revoked=True)
449
+ .values(revoked=True),
421
450
  ),
422
451
  )
423
452
  await self._commit(session)
@@ -425,19 +454,21 @@ class _BaseSQLAlchemyAdapter(
425
454
 
426
455
  async def revoke_refresh_token_family(self, family_id: str) -> None:
427
456
  async with self._begin() as session:
428
- await session.execute(
457
+ await self._execute(
458
+ session,
429
459
  update(self._refresh_token_model)
430
460
  .where(self._refresh_token_model.family_id == family_id)
431
- .values(revoked=True)
461
+ .values(revoked=True),
432
462
  )
433
463
  await self._commit(session)
434
464
 
435
465
  async def revoke_all_user_refresh_tokens(self, user_id: UserID) -> None:
436
466
  async with self._begin() as session:
437
- await session.execute(
467
+ await self._execute(
468
+ session,
438
469
  update(self._refresh_token_model)
439
470
  .where(self._refresh_token_model.user_id == user_id)
440
- .values(revoked=True)
471
+ .values(revoked=True),
441
472
  )
442
473
  await self._commit(session)
443
474
 
@@ -446,10 +477,11 @@ class _BaseSQLAlchemyAdapter(
446
477
  async with self._begin() as session:
447
478
  result = cast(
448
479
  "CursorResult[Any]",
449
- await session.execute(
480
+ await self._execute(
481
+ session,
450
482
  delete(self._refresh_token_model).where(
451
483
  self._refresh_token_model.expires_at < cutoff
452
- )
484
+ ),
453
485
  ),
454
486
  )
455
487
  await self._commit(session)
@@ -459,8 +491,8 @@ class _BaseSQLAlchemyAdapter(
459
491
  model = self._refresh_token_model
460
492
  now = datetime.now(timezone.utc)
461
493
  async with self._begin() as session:
462
- result = await session.execute(
463
- select(model).where(model.user_id == user_id).order_by(model.created_at)
494
+ result = await self._execute(
495
+ session, select(model).where(model.user_id == user_id).order_by(model.created_at)
464
496
  )
465
497
  rows = result.scalars().all()
466
498
 
@@ -496,19 +528,21 @@ class _BaseSQLAlchemyAdapter(
496
528
  # rows (re-revoking an already-revoked family) reports rowcount 0,
497
529
  # which would wrongly 404 a session the user still owns. The contract
498
530
  # is idempotent: True if a row existed for (user_id, family_id).
499
- existing = await session.execute(
531
+ existing = await self._execute(
532
+ session,
500
533
  select(model.id)
501
534
  .where(model.user_id == user_id)
502
535
  .where(model.family_id == family_id)
503
- .limit(1)
536
+ .limit(1),
504
537
  )
505
538
  if existing.first() is None:
506
539
  return False
507
- await session.execute(
540
+ await self._execute(
541
+ session,
508
542
  update(model)
509
543
  .where(model.user_id == user_id)
510
544
  .where(model.family_id == family_id)
511
- .values(revoked=True)
545
+ .values(revoked=True),
512
546
  )
513
547
  await self._commit(session)
514
548
  return True
@@ -518,12 +552,13 @@ class _BaseSQLAlchemyAdapter(
518
552
  async with self._begin() as session:
519
553
  result = cast(
520
554
  "CursorResult[Any]",
521
- await session.execute(
555
+ await self._execute(
556
+ session,
522
557
  update(model)
523
558
  .where(model.user_id == user_id)
524
559
  .where(model.family_id != keep_family_id)
525
560
  .where(model.revoked.is_(False))
526
- .values(revoked=True)
561
+ .values(revoked=True),
527
562
  ),
528
563
  )
529
564
  await self._commit(session)
@@ -531,10 +566,11 @@ class _BaseSQLAlchemyAdapter(
531
566
 
532
567
  async def set_user_verified(self, user_id: UserID) -> None:
533
568
  async with self._begin() as session:
534
- await session.execute(
569
+ await self._execute(
570
+ session,
535
571
  update(self._user_model)
536
572
  .where(self._user_model.id == user_id)
537
- .values(is_verified=True)
573
+ .values(is_verified=True),
538
574
  )
539
575
  await self._commit(session)
540
576
 
@@ -542,14 +578,18 @@ class _BaseSQLAlchemyAdapter(
542
578
  role_model = self._require(self._role_model, "Role assignment")
543
579
 
544
580
  async with self._begin() as session:
545
- result = await session.execute(select(role_model).where(role_model.name == role_name))
581
+ result = await self._execute(
582
+ session, select(role_model).where(role_model.name == role_name)
583
+ )
546
584
  role = result.scalars().first()
547
585
  if role is None:
548
586
  role = role_model(name=role_name)
549
587
  session.add(role)
550
588
  await session.flush()
551
589
 
552
- result = await session.execute(self._user_query().where(self._user_model.id == user_id))
590
+ result = await self._execute(
591
+ session, self._user_query().where(self._user_model.id == user_id)
592
+ )
553
593
  user = result.scalars().first()
554
594
  if user and role not in user.roles:
555
595
  user.roles.append(role)
@@ -558,7 +598,9 @@ class _BaseSQLAlchemyAdapter(
558
598
 
559
599
  async def remove_role(self, user_id: UserID, role_name: str) -> None:
560
600
  async with self._begin() as session:
561
- result = await session.execute(self._user_query().where(self._user_model.id == user_id))
601
+ result = await self._execute(
602
+ session, self._user_query().where(self._user_model.id == user_id)
603
+ )
562
604
  user = result.scalars().first()
563
605
  if user:
564
606
  user.roles = [r for r in user.roles if r.name != role_name]
@@ -573,14 +615,15 @@ class _BaseSQLAlchemyAdapter(
573
615
  role_permission_model = self._require(self._role_permission_model, "Permissions")
574
616
 
575
617
  async with self._begin() as session:
576
- result = await session.execute(
618
+ result = await self._execute(
619
+ session,
577
620
  select(permission_model.name)
578
621
  .join(
579
622
  role_permission_model,
580
623
  permission_model.id == role_permission_model.permission_id,
581
624
  )
582
625
  .join(role_model, role_model.id == role_permission_model.role_id)
583
- .where(role_model.name.in_(role_names))
626
+ .where(role_model.name.in_(role_names)),
584
627
  )
585
628
  return list(set(result.scalars().all()))
586
629
 
@@ -590,14 +633,15 @@ class _BaseSQLAlchemyAdapter(
590
633
  role_permission_model = self._require(self._role_permission_model, "Permissions")
591
634
 
592
635
  async with self._begin() as session:
593
- result = await session.execute(
636
+ result = await self._execute(
637
+ session,
594
638
  select(permission_model.name)
595
639
  .join(
596
640
  role_permission_model,
597
641
  permission_model.id == role_permission_model.permission_id,
598
642
  )
599
643
  .join(role_model, role_model.id == role_permission_model.role_id)
600
- .where(role_model.name == role_name)
644
+ .where(role_model.name == role_name),
601
645
  )
602
646
  return list(result.scalars().all())
603
647
 
@@ -607,15 +651,17 @@ class _BaseSQLAlchemyAdapter(
607
651
  role_permission_model = self._require(self._role_permission_model, "Permissions")
608
652
 
609
653
  async with self._begin() as session:
610
- result = await session.execute(select(role_model).where(role_model.name == role_name))
654
+ result = await self._execute(
655
+ session, select(role_model).where(role_model.name == role_name)
656
+ )
611
657
  role = result.scalars().first()
612
658
  if role is None:
613
659
  role = role_model(name=role_name)
614
660
  session.add(role)
615
661
  await session.flush()
616
662
 
617
- result = await session.execute(
618
- select(permission_model).where(permission_model.name == permission)
663
+ result = await self._execute(
664
+ session, select(permission_model).where(permission_model.name == permission)
619
665
  )
620
666
  perm = result.scalars().first()
621
667
  if perm is None:
@@ -623,11 +669,12 @@ class _BaseSQLAlchemyAdapter(
623
669
  session.add(perm)
624
670
  await session.flush()
625
671
 
626
- result = await session.execute(
672
+ result = await self._execute(
673
+ session,
627
674
  select(role_permission_model).where(
628
675
  role_permission_model.role_id == role.id,
629
676
  role_permission_model.permission_id == perm.id,
630
- )
677
+ ),
631
678
  )
632
679
  if result.scalars().first() is None:
633
680
  session.add(role_permission_model(role_id=role.id, permission_id=perm.id))
@@ -639,23 +686,26 @@ class _BaseSQLAlchemyAdapter(
639
686
  role_permission_model = self._require(self._role_permission_model, "Permissions")
640
687
 
641
688
  async with self._begin() as session:
642
- result = await session.execute(select(role_model).where(role_model.name == role_name))
689
+ result = await self._execute(
690
+ session, select(role_model).where(role_model.name == role_name)
691
+ )
643
692
  role = result.scalars().first()
644
693
  if role is None:
645
694
  return
646
695
 
647
- result = await session.execute(
648
- select(permission_model).where(permission_model.name == permission)
696
+ result = await self._execute(
697
+ session, select(permission_model).where(permission_model.name == permission)
649
698
  )
650
699
  perm = result.scalars().first()
651
700
  if perm is None:
652
701
  return
653
702
 
654
- result = await session.execute(
703
+ result = await self._execute(
704
+ session,
655
705
  select(role_permission_model).where(
656
706
  role_permission_model.role_id == role.id,
657
707
  role_permission_model.permission_id == perm.id,
658
- )
708
+ ),
659
709
  )
660
710
  link = result.scalars().first()
661
711
  if link:
@@ -678,11 +728,12 @@ class _BaseSQLAlchemyAdapter(
678
728
  async def get_oauth_account(self, provider: str, provider_user_id: str) -> OAuthAccount | None:
679
729
  oauth_model = self._require(self._oauth_account_model, "OAuth")
680
730
  async with self._begin() as session:
681
- result = await session.execute(
731
+ result = await self._execute(
732
+ session,
682
733
  select(oauth_model).where(
683
734
  oauth_model.provider == provider,
684
735
  oauth_model.provider_user_id == provider_user_id,
685
- )
736
+ ),
686
737
  )
687
738
  row = result.scalars().first()
688
739
  return self._to_oauth_account(row) if row else None
@@ -690,8 +741,8 @@ class _BaseSQLAlchemyAdapter(
690
741
  async def get_user_oauth_accounts(self, user_id: UserID) -> list[OAuthAccount]:
691
742
  oauth_model = self._require(self._oauth_account_model, "OAuth")
692
743
  async with self._begin() as session:
693
- result = await session.execute(
694
- select(oauth_model).where(oauth_model.user_id == user_id)
744
+ result = await self._execute(
745
+ session, select(oauth_model).where(oauth_model.user_id == user_id)
695
746
  )
696
747
  return [self._to_oauth_account(row) for row in result.scalars().all()]
697
748
 
@@ -713,11 +764,12 @@ class _BaseSQLAlchemyAdapter(
713
764
  # Concurrent OAuth callback for the same (provider, provider_user_id)
714
765
  # won the insert. Return the existing row; both callers linked the
715
766
  # same identity, which is the intended outcome.
716
- result = await session.execute(
767
+ result = await self._execute(
768
+ session,
717
769
  select(oauth_model).where(
718
770
  oauth_model.provider == data.provider,
719
771
  oauth_model.provider_user_id == data.provider_user_id,
720
- )
772
+ ),
721
773
  )
722
774
  existing = result.scalars().first()
723
775
  if existing is not None:
@@ -732,13 +784,14 @@ class _BaseSQLAlchemyAdapter(
732
784
  oauth_model = self._require(self._oauth_account_model, "OAuth")
733
785
  async with self._begin() as session:
734
786
  if data:
735
- await session.execute(
787
+ await self._execute(
788
+ session,
736
789
  update(oauth_model)
737
790
  .where(
738
791
  oauth_model.provider == provider,
739
792
  oauth_model.provider_user_id == provider_user_id,
740
793
  )
741
- .values(**data)
794
+ .values(**data),
742
795
  )
743
796
  await self._commit(session)
744
797
  return await self.get_oauth_account(provider, provider_user_id)
@@ -746,11 +799,12 @@ class _BaseSQLAlchemyAdapter(
746
799
  async def delete_oauth_account(self, provider: str, provider_user_id: str) -> None:
747
800
  oauth_model = self._require(self._oauth_account_model, "OAuth")
748
801
  async with self._begin() as session:
749
- result = await session.execute(
802
+ result = await self._execute(
803
+ session,
750
804
  select(oauth_model).where(
751
805
  oauth_model.provider == provider,
752
806
  oauth_model.provider_user_id == provider_user_id,
753
- )
807
+ ),
754
808
  )
755
809
  row = result.scalars().first()
756
810
  if row:
@@ -776,8 +830,8 @@ class _BaseSQLAlchemyAdapter(
776
830
  async def get_passkey_by_credential_id(self, credential_id: str) -> PasskeyCredential | None:
777
831
  passkey_model = self._require(self._passkey_model, "Passkeys")
778
832
  async with self._begin() as session:
779
- result = await session.execute(
780
- select(passkey_model).where(passkey_model.credential_id == credential_id)
833
+ result = await self._execute(
834
+ session, select(passkey_model).where(passkey_model.credential_id == credential_id)
781
835
  )
782
836
  row = result.scalars().first()
783
837
  return self._to_passkey(row) if row else None
@@ -785,8 +839,8 @@ class _BaseSQLAlchemyAdapter(
785
839
  async def get_user_passkeys(self, user_id: UserID) -> list[PasskeyCredential]:
786
840
  passkey_model = self._require(self._passkey_model, "Passkeys")
787
841
  async with self._begin() as session:
788
- result = await session.execute(
789
- select(passkey_model).where(passkey_model.user_id == user_id)
842
+ result = await self._execute(
843
+ session, select(passkey_model).where(passkey_model.user_id == user_id)
790
844
  )
791
845
  return [self._to_passkey(row) for row in result.scalars().all()]
792
846
 
@@ -813,11 +867,12 @@ class _BaseSQLAlchemyAdapter(
813
867
  async with self._begin() as session:
814
868
  result = cast(
815
869
  "CursorResult[Any]",
816
- await session.execute(
870
+ await self._execute(
871
+ session,
817
872
  update(passkey_model)
818
873
  .where(passkey_model.credential_id == credential_id)
819
874
  .where(passkey_model.sign_count < sign_count)
820
- .values(sign_count=sign_count, last_used_at=now)
875
+ .values(sign_count=sign_count, last_used_at=now),
821
876
  ),
822
877
  )
823
878
  if result.rowcount == 0:
@@ -825,10 +880,11 @@ class _BaseSQLAlchemyAdapter(
825
880
  # counter (both stored and new are 0) or a concurrent writer already wrote
826
881
  # a value >= ours. Touch last_used_at for the no-counter case; caller
827
882
  # decides whether to reject based on the new_sign_count value.
828
- await session.execute(
883
+ await self._execute(
884
+ session,
829
885
  update(passkey_model)
830
886
  .where(passkey_model.credential_id == credential_id)
831
- .values(last_used_at=now)
887
+ .values(last_used_at=now),
832
888
  )
833
889
  await self._commit(session)
834
890
  return False
@@ -838,8 +894,8 @@ class _BaseSQLAlchemyAdapter(
838
894
  async def delete_passkey(self, passkey_id: UUID) -> None:
839
895
  passkey_model = self._require(self._passkey_model, "Passkeys")
840
896
  async with self._begin() as session:
841
- result = await session.execute(
842
- select(passkey_model).where(passkey_model.id == passkey_id)
897
+ result = await self._execute(
898
+ session, select(passkey_model).where(passkey_model.id == passkey_id)
843
899
  )
844
900
  row = result.scalars().first()
845
901
  if row:
@@ -40,6 +40,14 @@ class AuthRateLimits:
40
40
  # bucket, so failed attempts cannot exhaust an IP's sign-in budget.
41
41
  reauth: int = 5
42
42
 
43
+ def __post_init__(self) -> None:
44
+ for name, value in vars(self).items():
45
+ if value < 1:
46
+ raise ValueError(
47
+ f"AUTH_RATE_LIMITS.{name} must be at least 1; "
48
+ f"{value} would reject every request to that route."
49
+ )
50
+
43
51
 
44
52
  class FullAuthConfig(BaseSettings):
45
53
  model_config = SettingsConfigDict(
@@ -61,19 +69,20 @@ class FullAuthConfig(BaseSettings):
61
69
  SECRET_KEY: str | None = None
62
70
  ALGORITHM: Literal["HS256", "HS384", "HS512"] = "HS256"
63
71
 
64
- ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
65
- REFRESH_TOKEN_EXPIRE_DAYS: int = 30
72
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, ge=1)
73
+ REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=30, ge=1)
66
74
  REFRESH_TOKEN_ROTATION: bool = True
67
- JWT_LEEWAY_SECONDS: int = 30
75
+ JWT_LEEWAY_SECONDS: int = Field(default=30, ge=0)
68
76
 
69
- PASSWORD_RESET_EXPIRE_MINUTES: int = 15
70
- EMAIL_VERIFY_EXPIRE_MINUTES: int = 1440
77
+ PASSWORD_RESET_EXPIRE_MINUTES: int = Field(default=15, ge=1)
78
+ EMAIL_VERIFY_EXPIRE_MINUTES: int = Field(default=1440, ge=1)
71
79
 
72
80
  PASSWORD_HASH_ALGORITHM: Literal["argon2id", "bcrypt"] = "argon2id"
73
- PASSWORD_MIN_LENGTH: int = 8
81
+ PASSWORD_MIN_LENGTH: int = Field(default=8, ge=1)
74
82
  # Upper bound so an attacker can't spend the server's CPU hashing a huge
75
83
  # body. bcrypt caps at 72 bytes anyway; argon2 will hash whatever it is given.
76
- PASSWORD_MAX_LENGTH: int = 4096
84
+ # 0 disables the cap, hence ge=0 rather than ge=1.
85
+ PASSWORD_MAX_LENGTH: int = Field(default=4096, ge=0)
77
86
 
78
87
  LOGIN_FIELD: str = "email"
79
88
  # When True, login runs a dummy password verify on unknown-user / no-password
@@ -86,12 +95,12 @@ class FullAuthConfig(BaseSettings):
86
95
  # destructive action (deleting the account, setting a first password).
87
96
  # Supplying the current password proves it too. 0 accepts only the password,
88
97
  # which locks passwordless accounts out of those actions.
89
- REAUTH_MAX_AGE_SECONDS: int = 300
98
+ REAUTH_MAX_AGE_SECONDS: int = Field(default=300, ge=0)
90
99
 
91
100
  LOCKOUT_ENABLED: bool = True
92
101
  LOCKOUT_BACKEND: Literal["memory", "redis"] = "memory"
93
- MAX_LOGIN_ATTEMPTS: int = 5
94
- LOCKOUT_DURATION_MINUTES: int = 15
102
+ MAX_LOGIN_ATTEMPTS: int = Field(default=5, ge=1)
103
+ LOCKOUT_DURATION_MINUTES: int = Field(default=15, ge=1)
95
104
 
96
105
  RATE_LIMIT_BACKEND: Literal["memory", "redis"] = "memory"
97
106
  TRUSTED_PROXY_HEADERS: Annotated[list[str], NoDecode] = []
@@ -104,14 +113,14 @@ class FullAuthConfig(BaseSettings):
104
113
 
105
114
  AUTH_RATE_LIMIT_ENABLED: bool = True
106
115
  AUTH_RATE_LIMITS: AuthRateLimits = Field(default_factory=AuthRateLimits)
107
- AUTH_RATE_LIMIT_WINDOW_SECONDS: int = 60
116
+ AUTH_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=60, ge=1)
108
117
 
109
118
  REDIS_URL: str | None = None
110
119
 
111
120
  BLACKLIST_ENABLED: bool = True
112
121
  BLACKLIST_BACKEND: Literal["memory", "redis"] = "memory"
113
122
 
114
- OAUTH_STATE_EXPIRE_SECONDS: int = 300
123
+ OAUTH_STATE_EXPIRE_SECONDS: int = Field(default=300, ge=1)
115
124
  OAUTH_AUTO_LINK_BY_EMAIL: bool = True
116
125
  OAUTH_PKCE_ENABLED: bool = True
117
126
 
@@ -124,7 +133,7 @@ class FullAuthConfig(BaseSettings):
124
133
  PASSKEY_RP_NAME: str | None = None
125
134
  PASSKEY_ORIGINS: Annotated[list[str], NoDecode] = []
126
135
  PASSKEY_CHALLENGE_BACKEND: Literal["memory", "redis"] = "memory"
127
- PASSKEY_CHALLENGE_TTL: int = 60
136
+ PASSKEY_CHALLENGE_TTL: int = Field(default=60, ge=1)
128
137
  PASSKEY_REQUIRE_USER_VERIFICATION: bool = True
129
138
 
130
139
  API_PREFIX: str = "/api/v1"