regstack 0.5.6__tar.gz → 0.5.9__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 (239) hide show
  1. {regstack-0.5.6 → regstack-0.5.9}/CHANGELOG.md +148 -0
  2. {regstack-0.5.6 → regstack-0.5.9}/PKG-INFO +1 -1
  3. {regstack-0.5.6 → regstack-0.5.9}/docs/changelog.md +112 -0
  4. {regstack-0.5.6 → regstack-0.5.9}/docs/configuration.md +64 -2
  5. {regstack-0.5.6 → regstack-0.5.9}/docs/security.md +7 -4
  6. {regstack-0.5.6 → regstack-0.5.9}/pyproject.toml +1 -1
  7. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/blacklist_repo.py +4 -1
  8. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +11 -2
  9. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -15
  10. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/protocols.py +14 -1
  11. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/oauth_state_repo.py +11 -2
  12. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/user.py +13 -0
  13. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/account.py +1 -1
  14. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/oauth.py +171 -15
  15. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/verify.py +6 -2
  16. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/static/js/regstack.js +9 -0
  17. regstack-0.5.9/src/regstack/version.py +1 -0
  18. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_oauth_google_router.py +93 -0
  19. {regstack-0.5.6 → regstack-0.5.9}/uv.lock +1 -1
  20. regstack-0.5.6/src/regstack/version.py +0 -1
  21. {regstack-0.5.6 → regstack-0.5.9}/.github/workflows/publish.yml +0 -0
  22. {regstack-0.5.6 → regstack-0.5.9}/.github/workflows/test.yml +0 -0
  23. {regstack-0.5.6 → regstack-0.5.9}/.gitignore +0 -0
  24. {regstack-0.5.6 → regstack-0.5.9}/.python-version +0 -0
  25. {regstack-0.5.6 → regstack-0.5.9}/.readthedocs.yaml +0 -0
  26. {regstack-0.5.6 → regstack-0.5.9}/CLAUDE.md +0 -0
  27. {regstack-0.5.6 → regstack-0.5.9}/LICENSE +0 -0
  28. {regstack-0.5.6 → regstack-0.5.9}/NOTICE +0 -0
  29. {regstack-0.5.6 → regstack-0.5.9}/README.md +0 -0
  30. {regstack-0.5.6 → regstack-0.5.9}/SECURITY.md +0 -0
  31. {regstack-0.5.6 → regstack-0.5.9}/docs/_static/.gitkeep +0 -0
  32. {regstack-0.5.6 → regstack-0.5.9}/docs/_templates/.gitkeep +0 -0
  33. {regstack-0.5.6 → regstack-0.5.9}/docs/api.md +0 -0
  34. {regstack-0.5.6 → regstack-0.5.9}/docs/architecture.md +0 -0
  35. {regstack-0.5.6 → regstack-0.5.9}/docs/cli.md +0 -0
  36. {regstack-0.5.6 → regstack-0.5.9}/docs/conf.py +0 -0
  37. {regstack-0.5.6 → regstack-0.5.9}/docs/embedding.md +0 -0
  38. {regstack-0.5.6 → regstack-0.5.9}/docs/index.md +0 -0
  39. {regstack-0.5.6 → regstack-0.5.9}/docs/oauth.md +0 -0
  40. {regstack-0.5.6 → regstack-0.5.9}/docs/quickstart.md +0 -0
  41. {regstack-0.5.6 → regstack-0.5.9}/docs/security-reports/README.md +0 -0
  42. {regstack-0.5.6 → regstack-0.5.9}/docs/theming.md +0 -0
  43. {regstack-0.5.6 → regstack-0.5.9}/examples/_common/__init__.py +0 -0
  44. {regstack-0.5.6 → regstack-0.5.9}/examples/_common/app.py +0 -0
  45. {regstack-0.5.6 → regstack-0.5.9}/examples/mongo/README.md +0 -0
  46. {regstack-0.5.6 → regstack-0.5.9}/examples/mongo/branding/theme.css +0 -0
  47. {regstack-0.5.6 → regstack-0.5.9}/examples/mongo/main.py +0 -0
  48. {regstack-0.5.6 → regstack-0.5.9}/examples/mongo/regstack.toml +0 -0
  49. {regstack-0.5.6 → regstack-0.5.9}/examples/postgres/README.md +0 -0
  50. {regstack-0.5.6 → regstack-0.5.9}/examples/postgres/main.py +0 -0
  51. {regstack-0.5.6 → regstack-0.5.9}/examples/postgres/regstack.toml +0 -0
  52. {regstack-0.5.6 → regstack-0.5.9}/examples/sqlite/README.md +0 -0
  53. {regstack-0.5.6 → regstack-0.5.9}/examples/sqlite/main.py +0 -0
  54. {regstack-0.5.6 → regstack-0.5.9}/examples/sqlite/regstack.toml +0 -0
  55. {regstack-0.5.6 → regstack-0.5.9}/regstack.toml.example +0 -0
  56. {regstack-0.5.6 → regstack-0.5.9}/scripts/ccr_coverage_setup.py +0 -0
  57. {regstack-0.5.6 → regstack-0.5.9}/scripts/security-review-prompt.md +0 -0
  58. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/__init__.py +0 -0
  59. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/app.py +0 -0
  60. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/__init__.py +0 -0
  61. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/clock.py +0 -0
  62. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/dependencies.py +0 -0
  63. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/jwt.py +0 -0
  64. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/lockout.py +0 -0
  65. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/mfa.py +0 -0
  66. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/password.py +0 -0
  67. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/rate_limit.py +0 -0
  68. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/auth/tokens.py +0 -0
  69. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/__init__.py +0 -0
  70. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/base.py +0 -0
  71. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/factory.py +0 -0
  72. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/__init__.py +0 -0
  73. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/backend.py +0 -0
  74. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/client.py +0 -0
  75. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/indexes.py +0 -0
  76. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
  77. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  78. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
  79. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
  80. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
  81. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/__init__.py +0 -0
  82. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/backend.py +0 -0
  83. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/migrations/__init__.py +0 -0
  84. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/migrations/env.py +0 -0
  85. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  86. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  87. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
  88. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/__init__.py +0 -0
  89. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  90. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  91. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  92. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
  93. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
  94. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  95. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/schema.py +0 -0
  96. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/backends/sql/types.py +0 -0
  97. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/cli/__init__.py +0 -0
  98. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/cli/__main__.py +0 -0
  99. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/cli/_runtime.py +0 -0
  100. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/cli/admin.py +0 -0
  101. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/cli/doctor.py +0 -0
  102. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/cli/init.py +0 -0
  103. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/cli/migrate.py +0 -0
  104. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/config/__init__.py +0 -0
  105. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/config/loader.py +0 -0
  106. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/config/schema.py +0 -0
  107. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/config/secrets.py +0 -0
  108. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/__init__.py +0 -0
  109. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/base.py +0 -0
  110. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/composer.py +0 -0
  111. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/console.py +0 -0
  112. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/factory.py +0 -0
  113. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/ses.py +0 -0
  114. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/smtp.py +0 -0
  115. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/email_change.html +0 -0
  116. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/email_change.subject.txt +0 -0
  117. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/email_change.txt +0 -0
  118. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/password_reset.html +0 -0
  119. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  120. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/password_reset.txt +0 -0
  121. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  122. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
  123. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/verification.html +0 -0
  124. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/verification.subject.txt +0 -0
  125. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/email/templates/verification.txt +0 -0
  126. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/hooks/__init__.py +0 -0
  127. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/hooks/events.py +0 -0
  128. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/__init__.py +0 -0
  129. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/_objectid.py +0 -0
  130. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/login_attempt.py +0 -0
  131. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/mfa_code.py +0 -0
  132. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/oauth_identity.py +0 -0
  133. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/oauth_state.py +0 -0
  134. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/models/pending_registration.py +0 -0
  135. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/oauth/__init__.py +0 -0
  136. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/oauth/base.py +0 -0
  137. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/oauth/errors.py +0 -0
  138. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/oauth/providers/__init__.py +0 -0
  139. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/oauth/providers/google.py +0 -0
  140. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/oauth/registry.py +0 -0
  141. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/__init__.py +0 -0
  142. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/_helpers.py +0 -0
  143. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/_schemas.py +0 -0
  144. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/admin.py +0 -0
  145. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/login.py +0 -0
  146. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/logout.py +0 -0
  147. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/password.py +0 -0
  148. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/phone.py +0 -0
  149. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/routers/register.py +0 -0
  150. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/sms/__init__.py +0 -0
  151. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/sms/base.py +0 -0
  152. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/sms/factory.py +0 -0
  153. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/sms/null.py +0 -0
  154. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/sms/sns.py +0 -0
  155. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/sms/twilio.py +0 -0
  156. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/__init__.py +0 -0
  157. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/pages.py +0 -0
  158. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/static/css/core.css +0 -0
  159. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/static/css/theme.css +0 -0
  160. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
  161. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/forgot.html +0 -0
  162. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/login.html +0 -0
  163. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/me.html +0 -0
  164. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  165. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
  166. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/register.html +0 -0
  167. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/reset.html +0 -0
  168. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/auth/verify.html +0 -0
  169. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/ui/templates/base.html +0 -0
  170. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/__init__.py +0 -0
  171. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/__init__.py +0 -0
  172. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/cli.py +0 -0
  173. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/routes.py +0 -0
  174. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/server.py +0 -0
  175. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
  176. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
  177. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
  178. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/validators.py +0 -0
  179. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/window.py +0 -0
  180. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/oauth_google/writer.py +0 -0
  181. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/__init__.py +0 -0
  182. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/cli.py +0 -0
  183. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/routes.py +0 -0
  184. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/server.py +0 -0
  185. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
  186. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
  187. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/templates/designer.html +0 -0
  188. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/validators.py +0 -0
  189. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/window.py +0 -0
  190. {regstack-0.5.6 → regstack-0.5.9}/src/regstack/wizard/theme_designer/writer.py +0 -0
  191. {regstack-0.5.6 → regstack-0.5.9}/tasks/oauth-design.md +0 -0
  192. {regstack-0.5.6 → regstack-0.5.9}/tasks.py +0 -0
  193. {regstack-0.5.6 → regstack-0.5.9}/tests/__init__.py +0 -0
  194. {regstack-0.5.6 → regstack-0.5.9}/tests/_fake_google/__init__.py +0 -0
  195. {regstack-0.5.6 → regstack-0.5.9}/tests/_fake_google/provider.py +0 -0
  196. {regstack-0.5.6 → regstack-0.5.9}/tests/conftest.py +0 -0
  197. {regstack-0.5.6 → regstack-0.5.9}/tests/e2e/__init__.py +0 -0
  198. {regstack-0.5.6 → regstack-0.5.9}/tests/e2e/conftest.py +0 -0
  199. {regstack-0.5.6 → regstack-0.5.9}/tests/e2e/test_theme_designer.py +0 -0
  200. {regstack-0.5.6 → regstack-0.5.9}/tests/e2e/test_wizard_oauth_flow.py +0 -0
  201. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/__init__.py +0 -0
  202. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_account_management.py +0 -0
  203. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_admin_router.py +0 -0
  204. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_happy_path.py +0 -0
  205. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_indexes.py +0 -0
  206. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_login_lockout.py +0 -0
  207. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_mfa.py +0 -0
  208. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_oauth_repos.py +0 -0
  209. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_oauth_ui.py +0 -0
  210. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_password_reset.py +0 -0
  211. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_rate_limits.py +0 -0
  212. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_sql_migrations.py +0 -0
  213. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_ui_router.py +0 -0
  214. {regstack-0.5.6 → regstack-0.5.9}/tests/integration/test_verification.py +0 -0
  215. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/__init__.py +0 -0
  216. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_base_install_imports.py +0 -0
  217. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_cli.py +0 -0
  218. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_cli_doctor.py +0 -0
  219. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_cli_init.py +0 -0
  220. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_cli_migrate.py +0 -0
  221. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_config_loader.py +0 -0
  222. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_jwt.py +0 -0
  223. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_lockout.py +0 -0
  224. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_mail_composer.py +0 -0
  225. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_mfa_code_repo.py +0 -0
  226. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_oauth_google.py +0 -0
  227. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_password.py +0 -0
  228. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_ses_backend.py +0 -0
  229. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_sms.py +0 -0
  230. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_smtp_backend.py +0 -0
  231. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_theme_designer_cli.py +0 -0
  232. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_theme_designer_routes.py +0 -0
  233. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_theme_designer_validators.py +0 -0
  234. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_theme_designer_writer.py +0 -0
  235. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_ui_env.py +0 -0
  236. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_wizard_oauth_cli.py +0 -0
  237. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_wizard_oauth_routes.py +0 -0
  238. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_wizard_oauth_validators.py +0 -0
  239. {regstack-0.5.6 → regstack-0.5.9}/tests/unit/test_wizard_oauth_writer.py +0 -0
@@ -5,6 +5,118 @@ authoritative copy lives at
5
5
  [`docs/changelog.md`](docs/changelog.md) and is rendered into the
6
6
  Sphinx docs.
7
7
 
8
+ ## 0.5.9 — 2026-05-13
9
+
10
+ **`OAuthConfig.enforce_mfa_on_oauth_signin` is now wired.** The flag
11
+ has been on the config since the OAuth router shipped (0.3.0) and the
12
+ wizard surfaced it, but the callback never read it — operators who
13
+ flipped it on still got OAuth sign-ins that bypassed the SMS second
14
+ factor. The High #1 finding from the post-0.5.6 consistency audit.
15
+
16
+ Now, when the flag is `true` and the resolved user has SMS MFA set
17
+ up (`is_mfa_enabled=True` plus a `phone_number`):
18
+
19
+ - The OAuth callback sends the SMS code and stashes a short-lived
20
+ `login_mfa` pending JWT in the state row (instead of a session
21
+ token).
22
+ - `POST /oauth/exchange` returns `mfa_required=True` and
23
+ `mfa_pending_token=...` (with no `access_token`) so the SPA knows
24
+ to redirect to `/account/mfa-confirm`.
25
+ - The SPA's bundled `regstack.js` `oauth-complete` handler stashes
26
+ the pending token under `regstack.mfa_pending` (same key the
27
+ password-login MFA flow uses) and redirects.
28
+ - The user enters the SMS code and hits the existing
29
+ `POST /login/mfa-confirm` endpoint — same downstream path as the
30
+ password-login second factor.
31
+
32
+ Link flows (`mode="link"`) are exempt: the user was already
33
+ authenticated when they kicked off the link, so adding SMS friction
34
+ on top of an already-authenticated link operation has no
35
+ threat-model win.
36
+
37
+ The `ExchangeResponse` model grew two optional fields
38
+ (`mfa_required: bool = False`, `mfa_pending_token: str | None = None`)
39
+ and `access_token` is now defaulted to `""` so the MFA branch can
40
+ return cleanly. Existing handlers reading `access_token` keep
41
+ working — they just need to check `mfa_required` first.
42
+
43
+ ## 0.5.8 — 2026-05-13
44
+
45
+ Audit-driven consistency cleanup — small fixes across the API surface
46
+ flagged by the post-0.5.6 consistency review.
47
+
48
+ **Security**
49
+
50
+ - **`oauth.completion_ttl_seconds` is finally enforced.** The flag has
51
+ been on `OAuthConfig` since the OAuth router shipped, but the
52
+ callback never used it: a state row stayed valid for the full
53
+ `state_ttl_seconds` (300s default) between callback completion and
54
+ `/oauth/exchange`. Now `set_result_token(...)` bumps the row's
55
+ expiry down to `now + completion_ttl_seconds` (30s default), so the
56
+ blast radius of a stolen state_id post-callback is the documented
57
+ 30-second window. `OAuthStateRepoProtocol.set_result_token` grew an
58
+ optional `new_expires_at=` kwarg to make this atomic with the
59
+ token write.
60
+
61
+ **Changed (UserPublic surface)**
62
+
63
+ - `UserPublic` now serialises `updated_at` and
64
+ `tokens_invalidated_after`. SPAs comparing the latter against their
65
+ cached session JWT's `iat` can detect a forced sign-out after a
66
+ password / email change without an extra round-trip.
67
+
68
+ **Changed (hook payloads)**
69
+
70
+ - `oauth_signin_started` in `mode="link"` now carries the
71
+ authenticated `user=` kwarg, matching `oauth_signin_completed` and
72
+ `oauth_account_linked`. The `mode="signin"` call site stays without
73
+ `user=` (there isn't one yet — sign-in is what produces it).
74
+
75
+ **Internal**
76
+
77
+ - `OAuthConfig.completion_ttl_seconds` config field is now load-bearing
78
+ (was previously declared-but-unread).
79
+ - `MessageResponse` in `routers/oauth.py` deleted; the router now uses
80
+ the shared one from `routers/_schemas.py`. OpenAPI no longer carries
81
+ two identically-named schemas.
82
+ - `MongoOAuthStateRepo` / `SqlOAuthStateRepo` `set_result_token` grew
83
+ a `new_expires_at` parameter (default `None`, so existing callers
84
+ see no change).
85
+ - `MongoBlacklistRepo.purge_expired` switched from `$lte` to `$lt` to
86
+ match the rest of the `purge_expired` family across both backends.
87
+ Edge-instant tokens get one more microsecond of life — the
88
+ bulk-revoke check (which DOES use `<=`) is unchanged.
89
+ - Dead `create()` and `delete_by_id()` methods removed from
90
+ `MongoPendingRepo` — neither was in the protocol or the SQL impl,
91
+ and nothing in src or tests called them.
92
+ - OAuth `start` and `callback` endpoints now declare
93
+ `response_class=RedirectResponse` and `status_code=302`. OpenAPI
94
+ surfaces the redirect intent properly.
95
+ - Custom-claim JWT encoder in `routers/account.py` (email-change
96
+ token) now emits `iat` as a float instead of `int`, matching the
97
+ three other custom-claim encoders and the bulk-revoke contract.
98
+ - `routers/verify.py` `created_at` for resent pending registrations
99
+ now goes through `rs.clock.now()` instead of wall-clock
100
+ `datetime.now(UTC)` — keeps `FrozenClock`-driven tests
101
+ deterministic.
102
+ - `BaseUser.model_config = ConfigDict(extra="allow")` got a
103
+ comment explaining why it's the only model in the package that
104
+ doesn't `extra="forbid"`.
105
+
106
+ ## 0.5.7 — 2026-05-13
107
+
108
+ Documentation-only follow-up to 0.5.6.
109
+
110
+ - `docs/configuration.md` now documents the per-route `*_rate_limit`
111
+ family (added in 0.5.4) instead of pointing at
112
+ `login_max_per_minute` / `login_max_per_hour` as reserved future
113
+ fields.
114
+ - `docs/security.md` no longer references
115
+ `PasswordHasher.needs_rehash` (removed in 0.5.6). Replacement
116
+ guidance points hosts at `pwdlib.PasswordHash.verify_and_update`.
117
+ - Root `CHANGELOG.md` backfilled with 0.4.0 and 0.5.0 entries so it
118
+ matches `docs/changelog.md`.
119
+
8
120
  ## 0.5.6 — 2026-05-13
9
121
 
10
122
  Eleven days of security-review remediation, supply-chain hardening,
@@ -86,6 +198,42 @@ to use it, call `pwdlib.PasswordHash.verify_and_update` directly.
86
198
  `routers/logout.py` (was listed in `KNOWN_EVENTS` but no router
87
199
  emitted it).
88
200
 
201
+ ## 0.5.0 — 2026-05-02
202
+
203
+ **Theme designer.** `regstack theme design` opens a native pywebview
204
+ window with controls for every `--rs-*` CSS custom property and a
205
+ real-time preview of the bundled SSR widgets (sign-in form, success /
206
+ error banners, danger-zone button). Saving writes `regstack-theme.css`;
207
+ the designer round-trips values back into the form on next launch so
208
+ iteration is non-destructive. `--print-only` mode takes repeatable
209
+ `--var NAME=VALUE` pairs (with a `dark:` prefix for dark-scheme
210
+ overrides) and writes the file headlessly. Lives in
211
+ `regstack.wizard.theme_designer`; registered as a lazy Click subgroup
212
+ so `regstack init` / `doctor` don't pay the pywebview/uvicorn import
213
+ cost.
214
+
215
+ **Docs.** New "About the examples" convention block at the top of
216
+ `docs/index.md`. Every URL, email, smtp host, and admin command across
217
+ the docs now extrapolates from the same fictional app at
218
+ `app.example.com` with `<username>` / `<password>` placeholders.
219
+
220
+ ## 0.4.0 — 2026-05-02
221
+
222
+ **OAuth setup wizard.** `regstack oauth setup` opens a native webview
223
+ window that walks an operator through registering a Google OAuth 2.0
224
+ client and merges the credentials into `regstack.toml` +
225
+ `regstack.secrets.env` non-destructively (preserves comments, other
226
+ tables, unrelated keys). 12-step SPA inside a local-only 127.0.0.1
227
+ FastAPI server, gated by a per-launch random token. Each "Next" click
228
+ hits a server-side validator so the Write step can never be reached
229
+ with bad data. `--print-only` mode skips the GUI for headless / CI
230
+ use.
231
+
232
+ Three new base dependencies — `pywebview>=5.0`, `tomlkit>=0.13`,
233
+ `uvicorn[standard]>=0.29` — for the wizard's local server.
234
+ `pytest-playwright` added to the `dev` extra; new `inv test-e2e` task
235
+ chained into `inv test-all`.
236
+
89
237
  ## 0.3.0 — 2026-04-30
90
238
 
91
239
  **OAuth — Sign in with Google.** Opt-in via the new `oauth` extra
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.5.6
3
+ Version: 0.5.9
4
4
  Summary: Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB.
5
5
  Project-URL: Homepage, https://github.com/jdrumgoole/regstack
6
6
  Project-URL: Repository, https://github.com/jdrumgoole/regstack
@@ -3,6 +3,118 @@
3
3
  All notable changes to this project are documented here. Versions follow
4
4
  [Semantic Versioning](https://semver.org/) once `1.0.0` ships.
5
5
 
6
+ ## 0.5.9 — 2026-05-13
7
+
8
+ ### Security
9
+
10
+ - **`OAuthConfig.enforce_mfa_on_oauth_signin` is now wired.** The flag
11
+ has been on the config since 0.3.0 and surfaced through the OAuth
12
+ setup wizard, but the callback never read it — operators who
13
+ enabled it still got OAuth sign-ins that bypassed the SMS second
14
+ factor. The High #1 finding from the post-0.5.6 consistency audit.
15
+
16
+ When the flag is `true` and the resolved user has SMS MFA set up
17
+ (`is_mfa_enabled=True` plus a `phone_number`), the OAuth callback
18
+ now sends the SMS code, stashes a short-lived `login_mfa` pending
19
+ JWT in the state row instead of a session token, and the SPA's
20
+ `regstack.js` `oauth-complete` handler reroutes through the
21
+ existing `/account/mfa-confirm` page → `POST /login/mfa-confirm`
22
+ flow.
23
+
24
+ Link flows (`mode="link"`) are intentionally exempt: the user was
25
+ already authenticated when they kicked off the link, so re-MFAing
26
+ is friction without a threat-model win.
27
+
28
+ ### Changed
29
+
30
+ - `ExchangeResponse` gained two optional fields:
31
+ - `mfa_required: bool = False`
32
+ - `mfa_pending_token: str | None = None`
33
+
34
+ `access_token` defaults to `""` (instead of being required) so the
35
+ MFA branch can return without one. Existing SPAs that read
36
+ `access_token` keep working — they just need to branch on
37
+ `mfa_required` first.
38
+
39
+ - `oauth_signin_completed` hook now carries `mfa_required=<bool>`
40
+ alongside the existing `user`, `provider`, `mode`, `was_new` kwargs
41
+ so observability handlers can distinguish "session minted" from
42
+ "MFA second-step in progress" outcomes.
43
+
44
+ ## 0.5.8 — 2026-05-13
45
+
46
+ Audit-driven consistency cleanup — small fixes across the API surface
47
+ flagged by the post-0.5.6 consistency review.
48
+
49
+ ### Security
50
+
51
+ - **`oauth.completion_ttl_seconds` is now enforced.** This flag has
52
+ been on `OAuthConfig` since the OAuth router shipped, but the
53
+ callback never used it. A state row stayed valid for the full
54
+ `state_ttl_seconds` (300s default) between callback completion and
55
+ the SPA's `/oauth/exchange` call. The callback now shortens the
56
+ row's `expires_at` to `now + completion_ttl_seconds` (30s default)
57
+ when it stashes the result token, so the blast radius of a stolen
58
+ state_id post-callback is the documented 30-second window.
59
+
60
+ ### Changed (UserPublic surface)
61
+
62
+ - `UserPublic` now serialises `updated_at` and
63
+ `tokens_invalidated_after`. SPAs comparing the latter against their
64
+ cached session JWT's `iat` can detect a forced sign-out after a
65
+ password / email change without an extra round-trip.
66
+
67
+ ### Changed (hook payloads)
68
+
69
+ - `oauth_signin_started` in `mode="link"` now carries the
70
+ authenticated `user=` kwarg, matching `oauth_signin_completed` and
71
+ `oauth_account_linked`. The `mode="signin"` call site stays
72
+ user-less (there isn't one yet — sign-in is what produces it).
73
+
74
+ ### Internal
75
+
76
+ - `OAuthStateRepoProtocol.set_result_token` grew an optional
77
+ `new_expires_at=` kwarg so the callback can re-set the row's
78
+ expiry atomically with the token write. Both Mongo and SQL impls
79
+ updated.
80
+ - `MessageResponse` in `routers/oauth.py` deleted; the router now
81
+ uses the shared one from `routers/_schemas.py`. OpenAPI no longer
82
+ carries two identically-named schemas.
83
+ - `MongoBlacklistRepo.purge_expired` switched from `$lte` to `$lt`
84
+ to match the rest of the `purge_expired` family. Bulk-revoke
85
+ (which DOES use `<=` for the conservative same-instant
86
+ interpretation) is unchanged.
87
+ - Dead `create()` / `delete_by_id()` methods removed from
88
+ `MongoPendingRepo` — neither was in the protocol or the SQL impl.
89
+ - OAuth `start` and `callback` endpoints now declare
90
+ `response_class=RedirectResponse` and `status_code=302`.
91
+ - Custom-claim JWT encoder in `routers/account.py` (email-change
92
+ token) now emits `iat` as a float instead of `int`, matching the
93
+ three other custom-claim encoders.
94
+ - `routers/verify.py` `created_at` for resent pending registrations
95
+ now goes through `rs.clock.now()` instead of wall-clock
96
+ `datetime.now(UTC)`.
97
+ - `BaseUser.model_config = ConfigDict(extra="allow")` is now
98
+ documented inline (it's the only model in the package that
99
+ doesn't `extra="forbid"`, on purpose).
100
+
101
+ ## 0.5.7 — 2026-05-13
102
+
103
+ Documentation-only follow-up to 0.5.6 — no runtime code changes.
104
+
105
+ ### Docs
106
+
107
+ - `docs/configuration.md` now documents the per-route `*_rate_limit`
108
+ family (added in 0.5.4) instead of pointing at
109
+ `login_max_per_minute` / `login_max_per_hour` as reserved future
110
+ fields.
111
+ - `docs/security.md` no longer references
112
+ `PasswordHasher.needs_rehash` (removed in 0.5.6). Replacement
113
+ guidance points hosts at `pwdlib.PasswordHash.verify_and_update`
114
+ inside a `user_logged_in` hook.
115
+ - Root `CHANGELOG.md` backfilled with 0.4.0 and 0.5.0 entries so it
116
+ matches this file. The two changelogs are now in sync.
117
+
6
118
  ## 0.5.6 — 2026-05-13
7
119
 
8
120
  A rollup release that consolidates 11 days of security-review
@@ -234,12 +234,74 @@ with no `mkdir` or `touch` step.
234
234
  - Sliding window for failures, also TTL on the `login_attempts` collection.
235
235
  * - `login_max_per_minute`
236
236
  - `5`
237
- - Reserved for a future route-level rate limiter.
237
+ - Deprecated since 0.5.4; kept for back-compat but no longer
238
+ wired. Use `login_rate_limit` (see "Per-route rate limits" below).
238
239
  * - `login_max_per_hour`
239
240
  - `20`
240
- - Same reservation.
241
+ - Same as above. Use a slowapi-syntax limit string on
242
+ `login_rate_limit` like `"5/minute;20/hour"`.
241
243
  ```
242
244
 
245
+ ## Per-route rate limits
246
+
247
+ New in 0.5.4. Opt-in: install the `rate_limit` extra (`pip install
248
+ 'regstack[rate_limit]'` — pulls in `slowapi`) or pass your own
249
+ `slowapi.Limiter` instance as `RegStack(rate_limiter=...)`.
250
+
251
+ Each field is a slowapi-syntax string. Empty / unset = no limit on
252
+ that route. The per-account `LockoutService` (see "Lockout (login)"
253
+ above) is unchanged and stacks on top of `login_rate_limit` — they
254
+ defend different axes: lockout defends one account against
255
+ credential-stuffing; the IP rate limits defend each endpoint
256
+ against one IP spamming requests across many accounts.
257
+
258
+ ```{list-table}
259
+ :header-rows: 1
260
+ :widths: 35 15 50
261
+
262
+ * - Field
263
+ - Default
264
+ - Notes
265
+
266
+ * - `login_rate_limit`
267
+ - `""`
268
+ - Per-IP on `POST /login` (and the MFA confirm step).
269
+ * - `register_rate_limit`
270
+ - `""`
271
+ - Per-IP on `POST /register`.
272
+ * - `forgot_password_rate_limit`
273
+ - `""`
274
+ - Per-IP on `POST /forgot-password`.
275
+ * - `reset_password_rate_limit`
276
+ - `""`
277
+ - Per-IP on `POST /reset-password`.
278
+ * - `verify_rate_limit`
279
+ - `""`
280
+ - Per-IP on `POST /verify`.
281
+ * - `resend_verification_rate_limit`
282
+ - `""`
283
+ - Per-IP on `POST /resend-verification`.
284
+ * - `change_password_rate_limit`
285
+ - `""`
286
+ - Per-IP on `POST /change-password`.
287
+ * - `change_email_rate_limit`
288
+ - `""`
289
+ - Per-IP on `POST /change-email`.
290
+ * - `confirm_email_change_rate_limit`
291
+ - `""`
292
+ - Per-IP on `POST /confirm-email-change`.
293
+ * - `delete_account_rate_limit`
294
+ - `""`
295
+ - Per-IP on `DELETE /account`.
296
+ ```
297
+
298
+ If any `*_rate_limit` is set but neither a `rate_limiter=` argument
299
+ nor the `rate_limit` extra is available, `RegStack.router` raises
300
+ `RuntimeError` on first access — failing closed beats silently
301
+ disabling the protection. The host owns `app.state.limiter` and
302
+ the `RateLimitExceeded` exception handler; slowapi itself defines
303
+ the 429 response shape.
304
+
243
305
  ## SMS / 2FA
244
306
 
245
307
  ```{list-table}
@@ -10,10 +10,13 @@ are tradeoffs, they're documented here rather than buried in code.
10
10
 
11
11
  ## Passwords
12
12
 
13
- - **Hashing.** Argon2id with library defaults.
14
- `PasswordHasher.needs_rehash(...)` is available so a future
15
- parameter bump can quietly upgrade existing hashes on a user's next
16
- successful login.
13
+ - **Hashing.** Argon2id with library defaults via `pwdlib`.
14
+ regstack does not expose a standalone "needs rehash?" method —
15
+ if the Argon2 parameters change later and you want existing
16
+ hashes upgraded on next login, call
17
+ `pwdlib.PasswordHash.verify_and_update(password, hashed)`
18
+ directly inside a host-side `user_logged_in` hook (it returns
19
+ both `(verified, new_hash_or_None)` in one pass).
17
20
  - **Length.** Minimum 8, maximum 128 (UTF-8). Validated by the
18
21
  pydantic input model on every create / change endpoint.
19
22
  - **Storage.** Plaintext is never logged or returned. The
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.5.6"
3
+ version = "0.5.9"
4
4
  description = "Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -36,5 +36,8 @@ class BlacklistRepo:
36
36
  # sweep — useful in tests and on Mongos where the TTL monitor's 60-second
37
37
  # cycle hasn't fired yet.
38
38
  reference = now or datetime.now(UTC)
39
- result = await self._collection.delete_many({"exp": {"$lte": reference}})
39
+ # Strict `<` matches the rest of the purge_expired family across both
40
+ # backends — a token whose exp is the reference instant is still
41
+ # nominally valid for one more microsecond.
42
+ result = await self._collection.delete_many({"exp": {"$lt": reference}})
40
43
  return int(result.deleted_count)
@@ -25,10 +25,19 @@ class MongoOAuthStateRepo:
25
25
  doc = await self._collection.find_one({"_id": state_id})
26
26
  return self._hydrate(doc)
27
27
 
28
- async def set_result_token(self, state_id: str, token: str) -> None:
28
+ async def set_result_token(
29
+ self,
30
+ state_id: str,
31
+ token: str,
32
+ *,
33
+ new_expires_at: datetime | None = None,
34
+ ) -> None:
35
+ updates: dict[str, Any] = {"result_token": token}
36
+ if new_expires_at is not None:
37
+ updates["expires_at"] = new_expires_at
29
38
  await self._collection.update_one(
30
39
  {"_id": state_id},
31
- {"$set": {"result_token": token}},
40
+ {"$set": updates},
32
41
  )
33
42
 
34
43
  async def consume(self, state_id: str) -> OAuthState | None:
@@ -4,9 +4,7 @@ from datetime import UTC, datetime
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from bson import ObjectId
7
- from pymongo.errors import DuplicateKeyError
8
7
 
9
- from regstack.backends.protocols import PendingAlreadyExistsError
10
8
  from regstack.models.pending_registration import PendingRegistration
11
9
 
12
10
  if TYPE_CHECKING:
@@ -36,14 +34,6 @@ class PendingRepo:
36
34
  pending.id = str(result["_id"])
37
35
  return pending
38
36
 
39
- async def create(self, pending: PendingRegistration) -> PendingRegistration:
40
- try:
41
- result = await self._collection.insert_one(pending.to_mongo())
42
- except DuplicateKeyError as exc:
43
- raise PendingAlreadyExistsError(pending.email) from exc
44
- pending.id = str(result.inserted_id)
45
- return pending
46
-
47
37
  async def find_by_token_hash(self, token_hash: str) -> PendingRegistration | None:
48
38
  doc = await self._collection.find_one({"token_hash": token_hash})
49
39
  return self._hydrate(doc)
@@ -52,11 +42,6 @@ class PendingRepo:
52
42
  doc = await self._collection.find_one({"email": email})
53
43
  return self._hydrate(doc)
54
44
 
55
- async def delete_by_id(self, pending_id: str) -> None:
56
- if not ObjectId.is_valid(pending_id):
57
- return
58
- await self._collection.delete_one({"_id": ObjectId(pending_id)})
59
-
60
45
  async def delete_by_email(self, email: str) -> None:
61
46
  await self._collection.delete_one({"email": email})
62
47
 
@@ -289,9 +289,22 @@ class OAuthStateRepoProtocol(Protocol):
289
289
 
290
290
  async def find(self, state_id: str) -> OAuthState | None: ...
291
291
 
292
- async def set_result_token(self, state_id: str, token: str) -> None:
292
+ async def set_result_token(
293
+ self,
294
+ state_id: str,
295
+ token: str,
296
+ *,
297
+ new_expires_at: datetime | None = None,
298
+ ) -> None:
293
299
  """Stash the session JWT after a successful callback so the
294
300
  SPA can pick it up via :meth:`consume`.
301
+
302
+ If ``new_expires_at`` is given, the row's ``expires_at`` is
303
+ bumped to that timestamp at the same time — this is how
304
+ callers shorten the redemption window from
305
+ ``oauth.state_ttl_seconds`` (covering the round-trip with
306
+ the provider) to ``oauth.completion_ttl_seconds`` (covering
307
+ only the SPA's exchange call after the callback lands).
295
308
  """
296
309
  ...
297
310
 
@@ -44,8 +44,17 @@ class SqlOAuthStateRepo:
44
44
  row = (await conn.execute(stmt)).first()
45
45
  return _row_to_state(row) if row else None
46
46
 
47
- async def set_result_token(self, state_id: str, token: str) -> None:
48
- stmt = update(self._t).where(self._t.c.id == state_id).values(result_token=token)
47
+ async def set_result_token(
48
+ self,
49
+ state_id: str,
50
+ token: str,
51
+ *,
52
+ new_expires_at: datetime | None = None,
53
+ ) -> None:
54
+ values: dict[str, Any] = {"result_token": token}
55
+ if new_expires_at is not None:
56
+ values["expires_at"] = new_expires_at
57
+ stmt = update(self._t).where(self._t.c.id == state_id).values(**values)
49
58
  async with self._engine.begin() as conn:
50
59
  await conn.execute(stmt)
51
60
 
@@ -23,6 +23,12 @@ class BaseUser(BaseModel):
23
23
  mixin via ``RegStack.extend_user_model``.
24
24
  """
25
25
 
26
+ # `extra="allow"` here (and only here) is deliberate: hosts that
27
+ # add their own user fields via subclassing or
28
+ # `RegStack.extend_user_model` need those fields to round-trip
29
+ # through the DB cleanly. Every request/response model
30
+ # (UserCreate, UserUpdate, UserPublic) uses `extra="forbid"`
31
+ # because those are the external API contract.
26
32
  model_config = ConfigDict(populate_by_name=True, extra="allow")
27
33
 
28
34
  id: IdStr | None = Field(default=None, alias="_id")
@@ -88,7 +94,12 @@ class UserPublic(BaseModel):
88
94
  phone_number: str | None = None
89
95
  is_mfa_enabled: bool = False
90
96
  created_at: datetime
97
+ updated_at: datetime
91
98
  last_login: datetime | None = None
99
+ tokens_invalidated_after: datetime | None = None
100
+ """Bulk-revoke cutoff. SPAs comparing this to their session JWT's
101
+ `iat` can tell when the token they hold has been invalidated by a
102
+ password change / email change without making another request."""
92
103
 
93
104
  @classmethod
94
105
  def from_user(cls, user: BaseUser) -> UserPublic:
@@ -104,5 +115,7 @@ class UserPublic(BaseModel):
104
115
  phone_number=user.phone_number,
105
116
  is_mfa_enabled=user.is_mfa_enabled,
106
117
  created_at=user.created_at,
118
+ updated_at=user.updated_at,
107
119
  last_login=user.last_login,
120
+ tokens_invalidated_after=user.tokens_invalidated_after,
108
121
  )
@@ -237,7 +237,7 @@ def _encode_email_change_token(rs: RegStack, user_id: str, new_email: str, ttl:
237
237
  payload: dict[str, Any] = {
238
238
  "sub": user_id,
239
239
  "jti": _secrets.token_urlsafe(16),
240
- "iat": int(now.timestamp()),
240
+ "iat": now.timestamp(),
241
241
  "exp": int((now + timedelta(seconds=ttl)).timestamp()),
242
242
  "purpose": _EMAIL_CHANGE_PURPOSE,
243
243
  _NEW_EMAIL_CLAIM: new_email,