regstack 0.8.2__tar.gz → 0.8.3__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 (185) hide show
  1. {regstack-0.8.2 → regstack-0.8.3}/CHANGELOG.md +55 -0
  2. {regstack-0.8.2 → regstack-0.8.3}/PKG-INFO +1 -1
  3. {regstack-0.8.2 → regstack-0.8.3}/pyproject.toml +4 -1
  4. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/clock.py +13 -4
  5. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +2 -1
  6. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/protocols.py +5 -0
  7. regstack-0.8.3/src/regstack/backends/sql/migrations/versions/0003_oauth_state_result_was_new.py +44 -0
  8. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/oauth_state_repo.py +3 -1
  9. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/schema.py +1 -0
  10. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/oauth_state.py +7 -0
  11. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/oauth/providers/google.py +24 -2
  12. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/oauth.py +4 -2
  13. regstack-0.8.3/src/regstack/version.py +1 -0
  14. regstack-0.8.2/src/regstack/version.py +0 -1
  15. {regstack-0.8.2 → regstack-0.8.3}/.gitignore +0 -0
  16. {regstack-0.8.2 → regstack-0.8.3}/LICENSE +0 -0
  17. {regstack-0.8.2 → regstack-0.8.3}/NOTICE +0 -0
  18. {regstack-0.8.2 → regstack-0.8.3}/README.md +0 -0
  19. {regstack-0.8.2 → regstack-0.8.3}/SECURITY.md +0 -0
  20. {regstack-0.8.2 → regstack-0.8.3}/regstack.toml.example +0 -0
  21. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/__init__.py +0 -0
  22. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/app.py +0 -0
  23. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/__init__.py +0 -0
  24. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/dependencies.py +0 -0
  25. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/jwt.py +0 -0
  26. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/lockout.py +0 -0
  27. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/mfa.py +0 -0
  28. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/password.py +0 -0
  29. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/rate_limit.py +0 -0
  30. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/auth/tokens.py +0 -0
  31. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/__init__.py +0 -0
  32. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/base.py +0 -0
  33. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/factory.py +0 -0
  34. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/__init__.py +0 -0
  35. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/backend.py +0 -0
  36. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/client.py +0 -0
  37. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/indexes.py +0 -0
  38. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
  39. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
  40. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  41. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
  42. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
  43. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
  44. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
  45. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/__init__.py +0 -0
  46. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/backend.py +0 -0
  47. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/migrations/__init__.py +0 -0
  48. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/migrations/env.py +0 -0
  49. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  50. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  51. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
  52. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/__init__.py +0 -0
  53. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  54. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  55. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  56. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
  57. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
  58. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  59. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/backends/sql/types.py +0 -0
  60. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/__init__.py +0 -0
  61. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/__main__.py +0 -0
  62. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/_paths.py +0 -0
  63. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/_results.py +0 -0
  64. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/_runtime.py +0 -0
  65. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/admin.py +0 -0
  66. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/doctor.py +0 -0
  67. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/init.py +0 -0
  68. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/migrate.py +0 -0
  69. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/__init__.py +0 -0
  70. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/capture.py +0 -0
  71. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/cli.py +0 -0
  72. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/http.py +0 -0
  73. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/logtail.py +0 -0
  74. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/__init__.py +0 -0
  75. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/account.py +0 -0
  76. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/cleanup.py +0 -0
  77. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/core_auth.py +0 -0
  78. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
  79. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/oauth.py +0 -0
  80. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/password_reset.py +0 -0
  81. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/reachability.py +0 -0
  82. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
  83. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/report.py +0 -0
  84. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/cli/validate/runner.py +0 -0
  85. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/config/__init__.py +0 -0
  86. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/config/loader.py +0 -0
  87. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/config/schema.py +0 -0
  88. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/config/secrets.py +0 -0
  89. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/__init__.py +0 -0
  90. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/base.py +0 -0
  91. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/composer.py +0 -0
  92. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/console.py +0 -0
  93. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/factory.py +0 -0
  94. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/ses.py +0 -0
  95. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/smtp.py +0 -0
  96. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/email_change.html +0 -0
  97. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/email_change.subject.txt +0 -0
  98. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/email_change.txt +0 -0
  99. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/password_reset.html +0 -0
  100. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  101. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/password_reset.txt +0 -0
  102. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  103. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
  104. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/verification.html +0 -0
  105. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/verification.subject.txt +0 -0
  106. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/email/templates/verification.txt +0 -0
  107. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/hooks/__init__.py +0 -0
  108. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/hooks/events.py +0 -0
  109. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/__init__.py +0 -0
  110. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/_objectid.py +0 -0
  111. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/login_attempt.py +0 -0
  112. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/mfa_code.py +0 -0
  113. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/oauth_identity.py +0 -0
  114. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/pending_registration.py +0 -0
  115. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/models/user.py +0 -0
  116. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/oauth/__init__.py +0 -0
  117. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/oauth/base.py +0 -0
  118. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/oauth/errors.py +0 -0
  119. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/oauth/providers/__init__.py +0 -0
  120. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/oauth/registry.py +0 -0
  121. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/__init__.py +0 -0
  122. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/_helpers.py +0 -0
  123. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/_schemas.py +0 -0
  124. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/account.py +0 -0
  125. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/admin.py +0 -0
  126. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/login.py +0 -0
  127. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/logout.py +0 -0
  128. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/password.py +0 -0
  129. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/phone.py +0 -0
  130. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/register.py +0 -0
  131. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/routers/verify.py +0 -0
  132. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/sms/__init__.py +0 -0
  133. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/sms/base.py +0 -0
  134. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/sms/factory.py +0 -0
  135. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/sms/null.py +0 -0
  136. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/sms/sns.py +0 -0
  137. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/sms/twilio.py +0 -0
  138. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/__init__.py +0 -0
  139. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/pages.py +0 -0
  140. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/static/css/core.css +0 -0
  141. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/static/css/theme.css +0 -0
  142. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/static/js/regstack.js +0 -0
  143. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/_token_handoff.html +0 -0
  144. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
  145. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/forgot.html +0 -0
  146. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/login.html +0 -0
  147. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/me.html +0 -0
  148. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  149. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
  150. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/register.html +0 -0
  151. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/reset.html +0 -0
  152. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/auth/verify.html +0 -0
  153. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/ui/templates/base.html +0 -0
  154. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/__init__.py +0 -0
  155. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/__init__.py +0 -0
  156. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/cli.py +0 -0
  157. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/routes.py +0 -0
  158. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/server.py +0 -0
  159. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
  160. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
  161. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
  162. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/validators.py +0 -0
  163. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/window.py +0 -0
  164. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/oauth_google/writer.py +0 -0
  165. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/__init__.py +0 -0
  166. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/_aws.py +0 -0
  167. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/cli.py +0 -0
  168. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/routes.py +0 -0
  169. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/server.py +0 -0
  170. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/static/wizard.css +0 -0
  171. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/static/wizard.js +0 -0
  172. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/templates/wizard.html +0 -0
  173. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/validators.py +0 -0
  174. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/window.py +0 -0
  175. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/ses/writer.py +0 -0
  176. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/__init__.py +0 -0
  177. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/cli.py +0 -0
  178. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/routes.py +0 -0
  179. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/server.py +0 -0
  180. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
  181. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
  182. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/templates/designer.html +0 -0
  183. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/validators.py +0 -0
  184. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/window.py +0 -0
  185. {regstack-0.8.2 → regstack-0.8.3}/src/regstack/wizard/theme_designer/writer.py +0 -0
@@ -7,6 +7,61 @@ Sphinx docs.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.8.3 — 2026-06-12
11
+
12
+ Closes out the 2026-05-15 → 2026-05-22 daily security-review series,
13
+ fixes `was_new_account` on the OAuth exchange (new migration `0003`),
14
+ and stops the test suite leaking MongoDB databases.
15
+
16
+ **Fixed: Google JWKS fetch now has a timeout.** `PyJWKClient` was
17
+ constructed without a `timeout`, so the synchronous `urllib` fetch
18
+ (offloaded to `asyncio.to_thread`) could pin a worker thread
19
+ indefinitely during a Google JWKS outage and, under sustained load,
20
+ exhaust the bounded asyncio thread pool. Added a 5-second
21
+ `JWKS_FETCH_TIMEOUT_SECONDS`. (Daily security review 2026-05-22 · W-1.)
22
+
23
+ **Fixed: Google OAuth token-exchange error no longer logs the full
24
+ provider response body at WARNING.** On a non-200 token exchange the
25
+ provider's response body is now logged at DEBUG; the raised
26
+ `OAuthTokenExchangeError` (which the router logs at WARNING) carries only
27
+ `HTTP <status>`. (Daily security review 2026-05-22 · I-3.)
28
+
29
+ **Fixed: `/oauth/exchange` now reports `was_new_account` accurately.**
30
+ The field was hardcoded `False`; the callback computed whether it created
31
+ a brand-new account but had nowhere to persist it. Added
32
+ `oauth_states.result_was_new` (migration `0003`) which the callback sets
33
+ and the exchange endpoint reads. (Daily security review 2026-05-22 · I-1.)
34
+
35
+ **Documented: `phone_number` exposure in the admin user listing.**
36
+ `docs/security.md` now spells out that `UserPublic.phone_number` is
37
+ returned in plaintext on `GET /admin/users` (regulated PII in some
38
+ jurisdictions) and that hosts wanting to mask/omit it should wrap the
39
+ admin listing in their own response model. (Daily security review
40
+ 2026-05-21 · I-2.)
41
+
42
+ **Fixed: `FrozenClock` now defaults to the far future (2125-01-01).**
43
+ MongoDB's TTL monitor uses real wall-clock time, so the old 2025-01-01
44
+ default meant every TTL-indexed test row was born already-expired and
45
+ could be reaped mid-test when a ~60s TTL sweep landed — a rare,
46
+ mongo-only parallel flake. A far-future pin keeps the reaper out of
47
+ reach while staying deterministic.
48
+
49
+ **Fixed: test runs no longer leak MongoDB databases.** Tests using the
50
+ `make_client` factory bypassed the only fixture that dropped the
51
+ per-test database, leaking one `regstack_test_*` DB per test per run.
52
+ A teardown fixture wired into `config` now drops the DB on every path,
53
+ a run-token-scoped `pytest_sessionfinish` sweep catches anything a
54
+ crashed worker leaves behind, and `inv clean-test-dbs` purges leftovers
55
+ from hard-killed runs.
56
+
57
+ **Triaged: CVE-2026-2978 / CVE-2026-2979 not applicable.** Flagged for
58
+ monitoring in the 2026-05-22 review; published NVD details show both
59
+ affect the third-party "FastApiAdmin" project, not the `fastapi`
60
+ library. regstack has no `fastapi-admin` dependency and no fastapi-core
61
+ advisories affect versions ≥ 0.120.0 (our floor). Triage note recorded
62
+ beside the floor in `pyproject.toml`. (Daily security review
63
+ 2026-05-22 · I-2.)
64
+
10
65
  ## 0.8.0 — 2026-05-19
11
66
 
12
67
  `regstack ses setup` guided wizard, plus two security fixes from
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.8.2
3
+ Version: 0.8.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.8.2"
3
+ version = "0.8.3"
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"
@@ -19,6 +19,9 @@ classifiers = [
19
19
  dependencies = [
20
20
  # fastapi>=0.120.0 picks up CVE-2025-62727 (Starlette DoS via large
21
21
  # request bodies after multipart processing).
22
+ # CVE-2026-2978 / CVE-2026-2979 (flagged for monitoring in the
23
+ # 2026-05-22 security review, I-2) affect the third-party
24
+ # "FastApiAdmin" project, not fastapi itself — no floor bump needed.
22
25
  "fastapi>=0.120.0",
23
26
  "pydantic>=2.6",
24
27
  "pydantic-settings>=2.2",
@@ -42,14 +42,23 @@ class FrozenClock:
42
42
  """
43
43
 
44
44
  def __init__(self, start: datetime | None = None) -> None:
45
- """Pin the clock at ``start`` (default 2025-01-01 UTC).
45
+ """Pin the clock at ``start`` (default 2125-01-01 UTC).
46
+
47
+ The default is deliberately ~100 years in the *future*. MongoDB's
48
+ TTL monitor deletes documents by comparing their ``expires_at``
49
+ against real wall-clock time on a ~60s cycle — it knows nothing
50
+ about this injected clock. A frozen "now" in the past would give
51
+ every TTL-indexed test row (oauth_states, pending_registrations,
52
+ mfa_codes, login_attempts, blacklist) an already-elapsed
53
+ ``expires_at``, and any test straddling a TTL sweep would have
54
+ its rows reaped mid-flight. A far-future pin keeps the reaper
55
+ permanently out of reach while staying deterministic.
46
56
 
47
57
  Args:
48
58
  start: The initial timestamp. Should be tz-aware. Defaults
49
- to ``2025-01-01T00:00:00Z`` so test datetimes are
50
- memorable.
59
+ to ``2125-01-01T00:00:00Z``.
51
60
  """
52
- self._now = start or datetime(2025, 1, 1, tzinfo=UTC)
61
+ self._now = start or datetime(2125, 1, 1, tzinfo=UTC)
53
62
 
54
63
  def now(self) -> datetime:
55
64
  """Return the currently-pinned timestamp."""
@@ -31,8 +31,9 @@ class MongoOAuthStateRepo:
31
31
  token: str,
32
32
  *,
33
33
  new_expires_at: datetime | None = None,
34
+ was_new: bool = False,
34
35
  ) -> None:
35
- updates: dict[str, Any] = {"result_token": token}
36
+ updates: dict[str, Any] = {"result_token": token, "result_was_new": was_new}
36
37
  if new_expires_at is not None:
37
38
  updates["expires_at"] = new_expires_at
38
39
  await self._collection.update_one(
@@ -295,6 +295,7 @@ class OAuthStateRepoProtocol(Protocol):
295
295
  token: str,
296
296
  *,
297
297
  new_expires_at: datetime | None = None,
298
+ was_new: bool = False,
298
299
  ) -> None:
299
300
  """Stash the session JWT after a successful callback so the
300
301
  SPA can pick it up via :meth:`consume`.
@@ -305,6 +306,10 @@ class OAuthStateRepoProtocol(Protocol):
305
306
  ``oauth.state_ttl_seconds`` (covering the round-trip with
306
307
  the provider) to ``oauth.completion_ttl_seconds`` (covering
307
308
  only the SPA's exchange call after the callback lands).
309
+
310
+ ``was_new`` records whether the callback created a brand-new
311
+ account, so the exchange response can report
312
+ ``was_new_account`` accurately.
308
313
  """
309
314
  ...
310
315
 
@@ -0,0 +1,44 @@
1
+ """Add oauth_states.result_was_new.
2
+
3
+ Revision ID: 0003
4
+ Revises: 0002
5
+ Create Date: 2026-06-04
6
+
7
+ The OAuth callback computes whether it created a brand-new account
8
+ (vs. signing an existing one in) but had nowhere to persist it, so the
9
+ ``POST /oauth/exchange`` response always reported
10
+ ``was_new_account=False``. This adds the column the callback writes and
11
+ the exchange endpoint reads (Security review 2026-05-22 · I-1).
12
+
13
+ The column is NOT NULL with a ``False`` server default so the in-flight
14
+ state rows that exist at migration time (if any) get a sensible value
15
+ without a backfill. ``batch_alter_table`` keeps the ADD COLUMN safe on
16
+ SQLite.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sqlalchemy as sa
22
+ from alembic import op
23
+
24
+ revision: str = "0003"
25
+ down_revision: str | None = "0002"
26
+ branch_labels: tuple[str, ...] | None = None
27
+ depends_on: str | None = None
28
+
29
+
30
+ def upgrade() -> None:
31
+ with op.batch_alter_table("oauth_states") as batch_op:
32
+ batch_op.add_column(
33
+ sa.Column(
34
+ "result_was_new",
35
+ sa.Boolean(),
36
+ nullable=False,
37
+ server_default=sa.false(),
38
+ )
39
+ )
40
+
41
+
42
+ def downgrade() -> None:
43
+ with op.batch_alter_table("oauth_states") as batch_op:
44
+ batch_op.drop_column("result_was_new")
@@ -34,6 +34,7 @@ class SqlOAuthStateRepo:
34
34
  "created_at": state.created_at,
35
35
  "expires_at": state.expires_at,
36
36
  "result_token": state.result_token,
37
+ "result_was_new": state.result_was_new,
37
38
  }
38
39
  async with self._engine.begin() as conn:
39
40
  await conn.execute(self._t.insert().values(values))
@@ -50,8 +51,9 @@ class SqlOAuthStateRepo:
50
51
  token: str,
51
52
  *,
52
53
  new_expires_at: datetime | None = None,
54
+ was_new: bool = False,
53
55
  ) -> None:
54
- values: dict[str, Any] = {"result_token": token}
56
+ values: dict[str, Any] = {"result_token": token, "result_was_new": was_new}
55
57
  if new_expires_at is not None:
56
58
  values["expires_at"] = new_expires_at
57
59
  stmt = update(self._t).where(self._t.c.id == state_id).values(**values)
@@ -162,6 +162,7 @@ def _oauth_states(table_name: str) -> Table:
162
162
  Column("created_at", UtcDateTime(), nullable=False),
163
163
  Column("expires_at", UtcDateTime(), nullable=False),
164
164
  Column("result_token", Text, nullable=True),
165
+ Column("result_was_new", Boolean, nullable=False, default=False),
165
166
  Index("ix_oauth_states_expires_at", "expires_at"),
166
167
  CheckConstraint("mode IN ('signin', 'link')", name="mode_valid"),
167
168
  )
@@ -77,6 +77,13 @@ class OAuthState(BaseModel):
77
77
  ``id`` for this value via ``POST /oauth/exchange``; the row is
78
78
  deleted on exchange."""
79
79
 
80
+ result_was_new: bool = False
81
+ """Whether the callback created a brand-new account (vs. signing in
82
+ an existing one). Set alongside ``result_token`` so the
83
+ ``POST /oauth/exchange`` response can surface ``was_new_account``
84
+ accurately — the callback computes this but had no field to persist
85
+ it on before (Security review 2026-05-22 · I-1)."""
86
+
80
87
  def to_mongo(self) -> dict[str, Any]:
81
88
  data = self.model_dump(by_alias=True, exclude_none=True)
82
89
  return data
@@ -17,6 +17,7 @@ installed and turns ``enable_oauth`` on.
17
17
  from __future__ import annotations
18
18
 
19
19
  import asyncio
20
+ import logging
20
21
  from typing import TYPE_CHECKING, Any
21
22
  from urllib.parse import urlencode
22
23
 
@@ -30,6 +31,8 @@ from regstack.oauth.errors import OAuthIdTokenError, OAuthTokenExchangeError
30
31
  if TYPE_CHECKING:
31
32
  from collections.abc import Iterable
32
33
 
34
+ log = logging.getLogger("regstack.oauth.google")
35
+
33
36
  GOOGLE_ISSUER = "https://accounts.google.com"
34
37
  GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
35
38
  GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
@@ -37,6 +40,13 @@ GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
37
40
 
38
41
  DEFAULT_SCOPES: tuple[str, ...] = ("openid", "email", "profile")
39
42
 
43
+ # Bound the synchronous JWKS fetch so a slow or unreachable Google JWKS
44
+ # endpoint can't pin a worker thread indefinitely. The fetch is offloaded
45
+ # to `asyncio.to_thread`, but `urllib` defaults to no timeout — under a
46
+ # sustained JWKS outage that would let cache-miss requests exhaust the
47
+ # bounded asyncio thread pool. (Security review 2026-05-22 · W-1.)
48
+ JWKS_FETCH_TIMEOUT_SECONDS = 5
49
+
40
50
 
41
51
  class GoogleProvider(OAuthProvider):
42
52
  """OIDC provider for Google.
@@ -80,7 +90,9 @@ class GoogleProvider(OAuthProvider):
80
90
  self._owns_http = http is None
81
91
  self._issuer = issuer
82
92
  self._scopes = tuple(scopes)
83
- self._jwks_client = PyJWKClient(jwks_url, cache_keys=True)
93
+ self._jwks_client = PyJWKClient(
94
+ jwks_url, cache_keys=True, timeout=JWKS_FETCH_TIMEOUT_SECONDS
95
+ )
84
96
 
85
97
  @property
86
98
  def name(self) -> str:
@@ -141,8 +153,18 @@ class GoogleProvider(OAuthProvider):
141
153
  if self._owns_http and client is not self._http:
142
154
  await client.aclose()
143
155
  if response.status_code != 200:
156
+ # Keep the provider's response body at DEBUG only. It doesn't
157
+ # carry our client secret or the auth code, but Google's error
158
+ # bodies are verbose and there's no reason to put them in
159
+ # production WARNING logs — the status code is the actionable
160
+ # signal. (Security review 2026-05-22 · I-3.)
161
+ log.debug(
162
+ "google token exchange response body (HTTP %s): %s",
163
+ response.status_code,
164
+ response.text,
165
+ )
144
166
  raise OAuthTokenExchangeError(
145
- f"google token exchange failed: {response.status_code} {response.text}"
167
+ f"google token exchange failed: HTTP {response.status_code}"
146
168
  )
147
169
  body: dict[str, Any] = response.json()
148
170
  try:
@@ -168,7 +168,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
168
168
  if _is_mfa_pending_token(state.result_token):
169
169
  return ExchangeResponse(
170
170
  redirect_to=state.redirect_to,
171
- was_new_account=False,
171
+ was_new_account=state.result_was_new,
172
172
  expires_in=rs.config.mfa_pending_token_ttl_seconds,
173
173
  mfa_required=True,
174
174
  mfa_pending_token=state.result_token,
@@ -176,7 +176,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
176
176
  return ExchangeResponse(
177
177
  access_token=state.result_token,
178
178
  redirect_to=state.redirect_to,
179
- was_new_account=False,
179
+ was_new_account=state.result_was_new,
180
180
  expires_in=rs.config.jwt_ttl_seconds,
181
181
  )
182
182
 
@@ -361,6 +361,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
361
361
  pending_token,
362
362
  new_expires_at=rs.clock.now()
363
363
  + timedelta(seconds=rs.config.mfa_pending_token_ttl_seconds),
364
+ was_new=was_new,
364
365
  )
365
366
  else:
366
367
  # Mint the session JWT, stash it on the state row for the
@@ -376,6 +377,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
376
377
  token,
377
378
  new_expires_at=rs.clock.now()
378
379
  + timedelta(seconds=rs.config.oauth.completion_ttl_seconds),
380
+ was_new=was_new,
379
381
  )
380
382
 
381
383
  await rs.hooks.fire(
@@ -0,0 +1 @@
1
+ __version__ = "0.8.3"
@@ -1 +0,0 @@
1
- __version__ = "0.8.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes