regstack 0.2.5__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. {regstack-0.2.5 → regstack-0.3.0}/CHANGELOG.md +47 -0
  2. {regstack-0.2.5 → regstack-0.3.0}/PKG-INFO +4 -1
  3. {regstack-0.2.5 → regstack-0.3.0}/docs/changelog.md +113 -0
  4. {regstack-0.2.5 → regstack-0.3.0}/docs/index.md +1 -0
  5. regstack-0.3.0/docs/oauth.md +135 -0
  6. {regstack-0.2.5 → regstack-0.3.0}/pyproject.toml +4 -1
  7. regstack-0.3.0/src/regstack/__init__.py +12 -0
  8. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/app.py +29 -0
  9. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/base.py +4 -0
  10. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/backend.py +6 -0
  11. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/indexes.py +27 -0
  12. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/__init__.py +6 -0
  13. regstack-0.3.0/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +63 -0
  14. regstack-0.3.0/src/regstack/backends/mongo/repositories/oauth_state_repo.py +45 -0
  15. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/pending_repo.py +4 -0
  16. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/protocols.py +105 -0
  17. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/backend.py +6 -0
  18. regstack-0.3.0/src/regstack/backends/sql/migrations/versions/0002_oauth.py +91 -0
  19. regstack-0.3.0/src/regstack/backends/sql/repositories/oauth_identity_repo.py +87 -0
  20. regstack-0.3.0/src/regstack/backends/sql/repositories/oauth_state_repo.py +66 -0
  21. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/pending_repo.py +9 -1
  22. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/schema.py +47 -1
  23. regstack-0.3.0/src/regstack/config/__init__.py +4 -0
  24. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/config/schema.py +37 -0
  25. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/hooks/events.py +4 -0
  26. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/__init__.py +5 -0
  27. regstack-0.3.0/src/regstack/models/oauth_identity.py +55 -0
  28. regstack-0.3.0/src/regstack/models/oauth_state.py +82 -0
  29. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/user.py +5 -1
  30. regstack-0.3.0/src/regstack/oauth/__init__.py +42 -0
  31. regstack-0.3.0/src/regstack/oauth/base.py +188 -0
  32. regstack-0.3.0/src/regstack/oauth/errors.py +43 -0
  33. regstack-0.3.0/src/regstack/oauth/providers/__init__.py +6 -0
  34. regstack-0.3.0/src/regstack/oauth/providers/google.py +200 -0
  35. regstack-0.3.0/src/regstack/oauth/registry.py +74 -0
  36. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/__init__.py +7 -0
  37. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/account.py +28 -0
  38. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/admin.py +1 -7
  39. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/login.py +7 -1
  40. regstack-0.3.0/src/regstack/routers/oauth.py +536 -0
  41. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/pages.py +12 -0
  42. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/static/js/regstack.js +133 -0
  43. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/login.html +14 -0
  44. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/me.html +8 -0
  45. regstack-0.3.0/src/regstack/ui/templates/auth/oauth_complete.html +14 -0
  46. regstack-0.3.0/src/regstack/version.py +1 -0
  47. regstack-0.3.0/tasks/oauth-design.md +729 -0
  48. regstack-0.3.0/tests/_fake_google/__init__.py +14 -0
  49. regstack-0.3.0/tests/_fake_google/provider.py +166 -0
  50. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_admin_router.py +56 -0
  51. regstack-0.3.0/tests/integration/test_oauth_google_router.py +583 -0
  52. regstack-0.3.0/tests/integration/test_oauth_repos.py +267 -0
  53. regstack-0.3.0/tests/integration/test_oauth_ui.py +168 -0
  54. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_base_install_imports.py +22 -12
  55. regstack-0.3.0/tests/unit/test_oauth_google.py +445 -0
  56. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_ui_env.py +1 -0
  57. {regstack-0.2.5 → regstack-0.3.0}/uv.lock +72 -2
  58. regstack-0.2.5/src/regstack/__init__.py +0 -5
  59. regstack-0.2.5/src/regstack/config/__init__.py +0 -4
  60. regstack-0.2.5/src/regstack/version.py +0 -1
  61. {regstack-0.2.5 → regstack-0.3.0}/.github/workflows/publish.yml +0 -0
  62. {regstack-0.2.5 → regstack-0.3.0}/.github/workflows/test.yml +0 -0
  63. {regstack-0.2.5 → regstack-0.3.0}/.gitignore +0 -0
  64. {regstack-0.2.5 → regstack-0.3.0}/.python-version +0 -0
  65. {regstack-0.2.5 → regstack-0.3.0}/.readthedocs.yaml +0 -0
  66. {regstack-0.2.5 → regstack-0.3.0}/CLAUDE.md +0 -0
  67. {regstack-0.2.5 → regstack-0.3.0}/LICENSE +0 -0
  68. {regstack-0.2.5 → regstack-0.3.0}/NOTICE +0 -0
  69. {regstack-0.2.5 → regstack-0.3.0}/README.md +0 -0
  70. {regstack-0.2.5 → regstack-0.3.0}/SECURITY.md +0 -0
  71. {regstack-0.2.5 → regstack-0.3.0}/docs/_static/.gitkeep +0 -0
  72. {regstack-0.2.5 → regstack-0.3.0}/docs/_templates/.gitkeep +0 -0
  73. {regstack-0.2.5 → regstack-0.3.0}/docs/api.md +0 -0
  74. {regstack-0.2.5 → regstack-0.3.0}/docs/architecture.md +0 -0
  75. {regstack-0.2.5 → regstack-0.3.0}/docs/cli.md +0 -0
  76. {regstack-0.2.5 → regstack-0.3.0}/docs/conf.py +0 -0
  77. {regstack-0.2.5 → regstack-0.3.0}/docs/configuration.md +0 -0
  78. {regstack-0.2.5 → regstack-0.3.0}/docs/embedding.md +0 -0
  79. {regstack-0.2.5 → regstack-0.3.0}/docs/quickstart.md +0 -0
  80. {regstack-0.2.5 → regstack-0.3.0}/docs/security.md +0 -0
  81. {regstack-0.2.5 → regstack-0.3.0}/docs/theming.md +0 -0
  82. {regstack-0.2.5 → regstack-0.3.0}/examples/_common/__init__.py +0 -0
  83. {regstack-0.2.5 → regstack-0.3.0}/examples/_common/app.py +0 -0
  84. {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/README.md +0 -0
  85. {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/branding/theme.css +0 -0
  86. {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/main.py +0 -0
  87. {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/regstack.toml +0 -0
  88. {regstack-0.2.5 → regstack-0.3.0}/examples/postgres/README.md +0 -0
  89. {regstack-0.2.5 → regstack-0.3.0}/examples/postgres/main.py +0 -0
  90. {regstack-0.2.5 → regstack-0.3.0}/examples/postgres/regstack.toml +0 -0
  91. {regstack-0.2.5 → regstack-0.3.0}/examples/sqlite/README.md +0 -0
  92. {regstack-0.2.5 → regstack-0.3.0}/examples/sqlite/main.py +0 -0
  93. {regstack-0.2.5 → regstack-0.3.0}/examples/sqlite/regstack.toml +0 -0
  94. {regstack-0.2.5 → regstack-0.3.0}/regstack.toml.example +0 -0
  95. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/__init__.py +0 -0
  96. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/clock.py +0 -0
  97. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/dependencies.py +0 -0
  98. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/jwt.py +0 -0
  99. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/lockout.py +0 -0
  100. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/mfa.py +0 -0
  101. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/password.py +0 -0
  102. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/tokens.py +0 -0
  103. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/__init__.py +0 -0
  104. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/factory.py +0 -0
  105. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/__init__.py +0 -0
  106. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/client.py +0 -0
  107. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
  108. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  109. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
  110. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
  111. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/__init__.py +0 -0
  112. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/__init__.py +0 -0
  113. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/env.py +0 -0
  114. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  115. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  116. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/__init__.py +0 -0
  117. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  118. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  119. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  120. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  121. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/types.py +0 -0
  122. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/__init__.py +0 -0
  123. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/__main__.py +0 -0
  124. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/_runtime.py +0 -0
  125. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/admin.py +0 -0
  126. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/doctor.py +0 -0
  127. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/init.py +0 -0
  128. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/migrate.py +0 -0
  129. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/config/loader.py +0 -0
  130. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/config/secrets.py +0 -0
  131. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/__init__.py +0 -0
  132. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/base.py +0 -0
  133. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/composer.py +0 -0
  134. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/console.py +0 -0
  135. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/factory.py +0 -0
  136. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/ses.py +0 -0
  137. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/smtp.py +0 -0
  138. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/email_change.html +0 -0
  139. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/email_change.subject.txt +0 -0
  140. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/email_change.txt +0 -0
  141. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/password_reset.html +0 -0
  142. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  143. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/password_reset.txt +0 -0
  144. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  145. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
  146. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/verification.html +0 -0
  147. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/verification.subject.txt +0 -0
  148. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/verification.txt +0 -0
  149. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/hooks/__init__.py +0 -0
  150. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/_objectid.py +0 -0
  151. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/login_attempt.py +0 -0
  152. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/mfa_code.py +0 -0
  153. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/pending_registration.py +0 -0
  154. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/_schemas.py +0 -0
  155. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/logout.py +0 -0
  156. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/password.py +0 -0
  157. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/phone.py +0 -0
  158. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/register.py +0 -0
  159. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/verify.py +0 -0
  160. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/__init__.py +0 -0
  161. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/base.py +0 -0
  162. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/factory.py +0 -0
  163. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/null.py +0 -0
  164. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/sns.py +0 -0
  165. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/twilio.py +0 -0
  166. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/__init__.py +0 -0
  167. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/static/css/core.css +0 -0
  168. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/static/css/theme.css +0 -0
  169. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
  170. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/forgot.html +0 -0
  171. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  172. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/register.html +0 -0
  173. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/reset.html +0 -0
  174. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/verify.html +0 -0
  175. {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/base.html +0 -0
  176. {regstack-0.2.5 → regstack-0.3.0}/tasks.py +0 -0
  177. {regstack-0.2.5 → regstack-0.3.0}/tests/__init__.py +0 -0
  178. {regstack-0.2.5 → regstack-0.3.0}/tests/conftest.py +0 -0
  179. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/__init__.py +0 -0
  180. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_account_management.py +0 -0
  181. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_happy_path.py +0 -0
  182. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_indexes.py +0 -0
  183. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_login_lockout.py +0 -0
  184. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_mfa.py +0 -0
  185. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_password_reset.py +0 -0
  186. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_sql_migrations.py +0 -0
  187. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_ui_router.py +0 -0
  188. {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_verification.py +0 -0
  189. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/__init__.py +0 -0
  190. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_cli.py +0 -0
  191. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_cli_doctor.py +0 -0
  192. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_cli_init.py +0 -0
  193. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_config_loader.py +0 -0
  194. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_jwt.py +0 -0
  195. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_lockout.py +0 -0
  196. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_mail_composer.py +0 -0
  197. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_mfa_code_repo.py +0 -0
  198. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_password.py +0 -0
  199. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_ses_backend.py +0 -0
  200. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_sms.py +0 -0
  201. {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_smtp_backend.py +0 -0
@@ -5,6 +5,53 @@ 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.3.0 — 2026-04-30
9
+
10
+ **OAuth — Sign in with Google.** Opt-in via the new `oauth` extra
11
+ and `enable_oauth=True`. Five JSON endpoints, an SSR
12
+ `/account/oauth-complete` page, "Sign in with Google" button on the
13
+ login page, and a Connected-accounts panel on `/account/me`.
14
+
15
+ Schema migration `0002_oauth.py` creates `oauth_identities` +
16
+ `oauth_states` and makes `users.hashed_password` nullable
17
+ (OAuth-only users have no password). Roll forward via
18
+ `regstack migrate` or first-boot `install_schema()` — no manual
19
+ intervention.
20
+
21
+ Account-linking policy defaults to **refuse**: if a Google sign-in
22
+ arrives carrying an email that already belongs to a password-
23
+ registered user, the callback returns `?error=email_in_use` and the
24
+ user must sign in then explicitly link from `/account/me`. Hosts
25
+ that consciously accept the email-recycling threat for UX can flip
26
+ `oauth.auto_link_verified_emails = true`. See
27
+ [`docs/oauth.md`](https://regstack.readthedocs.io/en/latest/oauth.html)
28
+ and [`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md)
29
+ for the full threat model.
30
+
31
+ **Migration**
32
+
33
+ - Install the new extra: `uv add 'regstack[oauth]'`.
34
+ - Set `enable_oauth = true` and provide `oauth.google_client_id` +
35
+ `oauth.google_client_secret`.
36
+ - Run `regstack migrate` (SQL backends only) or rely on
37
+ `install_schema()` at first boot.
38
+
39
+ `BaseUser.hashed_password` is now `str | None`. Code that imported
40
+ the field type explicitly will need to widen it.
41
+
42
+ ## 0.2.6 — 2026-04-28
43
+
44
+ Bug fix.
45
+
46
+ - **Fix:** `/admin/stats` reported `pending_registrations: 0` on
47
+ every SQL backend. The route reached into the Mongo repo's private
48
+ `_collection` attribute and silently fell back to `0` when the
49
+ attribute was absent. Added `count_unexpired(now=None)` to
50
+ `PendingRepoProtocol` with Mongo + SQL implementations and routed
51
+ through `rs.clock.now()` so the count respects the injected clock.
52
+ New parametrized integration test exercises the count on every
53
+ backend.
54
+
8
55
  ## 0.2.5 — 2026-04-28
9
56
 
10
57
  Bug fix + tooling.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.2.5
3
+ Version: 0.3.0
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
@@ -36,6 +36,7 @@ Requires-Dist: asyncpg>=0.29; extra == 'dev'
36
36
  Requires-Dist: httpx>=0.27; extra == 'dev'
37
37
  Requires-Dist: invoke>=2.2; extra == 'dev'
38
38
  Requires-Dist: mypy>=1.10; extra == 'dev'
39
+ Requires-Dist: pyjwt[crypto]>=2.8; extra == 'dev'
39
40
  Requires-Dist: pymongo>=4.9; extra == 'dev'
40
41
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
41
42
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
@@ -52,6 +53,8 @@ Requires-Dist: sphinx-copybutton>=0.5; extra == 'docs'
52
53
  Requires-Dist: sphinx>=7.3; extra == 'docs'
53
54
  Provides-Extra: mongo
54
55
  Requires-Dist: pymongo>=4.9; extra == 'mongo'
56
+ Provides-Extra: oauth
57
+ Requires-Dist: pyjwt[crypto]>=2.8; extra == 'oauth'
55
58
  Provides-Extra: postgres
56
59
  Requires-Dist: asyncpg>=0.29; extra == 'postgres'
57
60
  Provides-Extra: ses
@@ -3,6 +3,119 @@
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.3.0 — 2026-04-30
7
+
8
+ **OAuth — Sign in with Google.** Built across four PRs (M1–M4 of
9
+ [`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md));
10
+ this is the release cut that wraps them up.
11
+
12
+ ### Added
13
+
14
+ - New optional extra `oauth = ["pyjwt[crypto]>=2.8"]`.
15
+ - New `enable_oauth` flag and `OAuthConfig` sub-model
16
+ (`google_client_id`, `google_client_secret`, `google_redirect_uri`,
17
+ `auto_link_verified_emails`, `enforce_mfa_on_oauth_signin`,
18
+ `state_ttl_seconds`, `completion_ttl_seconds`).
19
+ - `regstack.oauth` package — `OAuthProvider` ABC, `OAuthRegistry`,
20
+ `OAuthTokens`, `OAuthUserInfo`, error hierarchy, and the concrete
21
+ `GoogleProvider` (Authorization Code with PKCE, ID-token
22
+ verification via `pyjwt[crypto]` + `PyJWKClient` against Google's
23
+ JWKS).
24
+ - Five JSON endpoints (mounted lazily when `enable_oauth=True` and a
25
+ provider is registered):
26
+ - `GET /oauth/{provider}/start`
27
+ - `GET /oauth/{provider}/callback`
28
+ - `POST /oauth/exchange`
29
+ - `POST /oauth/{provider}/link/start` (auth)
30
+ - `DELETE /oauth/{provider}/link` (auth)
31
+ - `GET /oauth/providers` (auth)
32
+ - New SSR page `/account/oauth-complete` (token-handoff round-trip).
33
+ - "Sign in with Google" button on `/account/login` and a Connected-
34
+ accounts panel on `/account/me`. Login page surfaces callback
35
+ errors via `?error=<code>` with translated banners.
36
+ - Two new repo protocols: `OAuthIdentityRepoProtocol`,
37
+ `OAuthStateRepoProtocol`. Mongo + SQL implementations with
38
+ parametrized integration tests over all three backends.
39
+ - Four new hook events: `oauth_signin_started`,
40
+ `oauth_signin_completed`, `oauth_account_linked`,
41
+ `oauth_account_unlinked`.
42
+ - `tests/_fake_google/` — in-process provider stub so the OAuth
43
+ test suite stays offline and parallel-safe.
44
+ - New docs page [`docs/oauth.md`](oauth.md) — host guide.
45
+
46
+ ### Changed (potentially breaking)
47
+
48
+ - **`BaseUser.hashed_password: str` → `str | None`.** OAuth-only
49
+ users have no password. The login route rejects password attempts
50
+ on these accounts with the same generic 401 wrong-password gets
51
+ (no enumeration). `change-password`, `change-email`, and
52
+ `delete-account` all return 400 for OAuth-only users with a
53
+ pointer at the password-reset flow, which doubles as a "set
54
+ initial password" path.
55
+ - `users.hashed_password` is now nullable in the SQL schema —
56
+ migration `0002_oauth.py` flips the column via `batch_alter_table`
57
+ (SQLite-safe). Existing rows are unaffected.
58
+ - New SQL tables `oauth_identities` and `oauth_states`. Mongo
59
+ collections + indexes added by `install_schema()`.
60
+
61
+ ### Security defaults
62
+
63
+ - **Account-linking policy defaults to refuse.** When a Google
64
+ sign-in carries an email that already belongs to a regstack user,
65
+ the callback returns `?error=email_in_use`. Hosts can opt into
66
+ auto-linking via `oauth.auto_link_verified_emails = true`, which
67
+ also requires `email_verified=true` on the ID token. The threat
68
+ model is in `tasks/oauth-design.md` § 1.
69
+ - **Server-side PKCE.** `code_verifier` is stored on the
70
+ `oauth_states` row and never enters the URL.
71
+ - **One-time token-handoff.** `/oauth/exchange` consumes the state
72
+ row atomically; second exchange returns 404.
73
+ - **Refuse to unlink the only sign-in method.** Returns 400 for
74
+ OAuth-only users attempting to unlink their only provider.
75
+ - **OAuth sessions are normal session JWTs** — the existing
76
+ `tokens_invalidated_after` bulk-revoke applies, so a password
77
+ change kills any OAuth-issued session too.
78
+
79
+ ### Migration notes
80
+
81
+ - Install the extra: `uv add 'regstack[oauth]'`.
82
+ - Configure: set `enable_oauth = true` and provide
83
+ `oauth.google_client_id` + `oauth.google_client_secret` (the
84
+ secret in `regstack.secrets.env` as
85
+ `REGSTACK_OAUTH__GOOGLE_CLIENT_SECRET`).
86
+ - Schema: roll forward via `regstack migrate` or rely on
87
+ `install_schema()` at first boot.
88
+
89
+ ## 0.2.6 — 2026-04-28
90
+
91
+ **Bug fix.**
92
+
93
+ ### Fixed
94
+
95
+ - ``/admin/stats`` reported ``pending_registrations: 0`` on every SQL
96
+ backend. The route reached into the Mongo repo's private
97
+ ``_collection`` attribute and silently fell back to ``0`` when the
98
+ attribute was absent — the kind of failure that survives a
99
+ multi-backend refactor when the integration tests don't pin the
100
+ number.
101
+
102
+ ### Added
103
+
104
+ - ``PendingRepoProtocol.count_unexpired(now=None) -> int``, with Mongo
105
+ and SQL implementations. "Unexpired" rather than a raw row count
106
+ because SQL backends accumulate dead rows until ``purge_expired``
107
+ runs; an admin looking at "pending: 47" wants 47 *live* rows.
108
+ - The admin stats route now routes the count through
109
+ ``rs.clock.now()``. Without this, ``FrozenClock``-driven tests
110
+ would see every row as "expired" because the route would be reading
111
+ wall-clock time while the rest of the system runs on the injected
112
+ clock. Same shape of clock-injection drift the bulk-revoke fix
113
+ closed earlier.
114
+ - New parametrized integration test
115
+ ``test_stats_pending_registrations_count_unexpired`` runs against
116
+ SQLite + Mongo + Postgres and confirms the count excludes expired
117
+ rows on every backend.
118
+
6
119
  ## 0.2.5 — 2026-04-28
7
120
 
8
121
  **Bug fix + tooling.**
@@ -120,6 +120,7 @@ configuration
120
120
  architecture
121
121
  security
122
122
  embedding
123
+ oauth
123
124
  theming
124
125
  cli
125
126
  ```
@@ -0,0 +1,135 @@
1
+ # OAuth (Sign in with Google)
2
+
3
+ regstack ships an opt-in OAuth subsystem. v1 supports Google; the
4
+ abstraction is shaped so adding GitHub / Microsoft / Apple later is a
5
+ new module under `regstack/oauth/providers/` plus one config field.
6
+
7
+ This page walks a host through enabling it. The full design — including
8
+ the threat model and the four-milestone build sequence the
9
+ implementation followed — is in
10
+ [`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md).
11
+
12
+ ## What you get
13
+
14
+ When OAuth is enabled and at least one provider is configured:
15
+
16
+ - **Five JSON endpoints** under `/api/auth/oauth/`:
17
+ - `GET /oauth/{provider}/start` — public; redirects to the provider.
18
+ - `GET /oauth/{provider}/callback` — public; handles the redirect back.
19
+ - `POST /oauth/exchange` — single-use; SPA trades the state-id for a
20
+ session JWT.
21
+ - `POST /oauth/{provider}/link/start` — authenticated; returns the URL
22
+ to navigate the browser to.
23
+ - `DELETE /oauth/{provider}/link` — authenticated; unlinks one identity.
24
+ - `GET /oauth/providers` — authenticated; lists configured + linked
25
+ providers (drives the SSR connected-accounts panel).
26
+ - **A "Sign in with Google" button** on the bundled SSR login page.
27
+ - **A "Connected accounts" panel** on the SSR `/account/me` page.
28
+ - **Four hook events**: `oauth_signin_started`, `oauth_signin_completed`,
29
+ `oauth_account_linked`, `oauth_account_unlinked`.
30
+
31
+ ## Install the extra
32
+
33
+ ```bash
34
+ uv add 'regstack[oauth]'
35
+ ```
36
+
37
+ The `oauth` extra pulls in `pyjwt[crypto]>=2.8`, which transitively
38
+ includes `cryptography`. ID-token signature verification needs RSA, so
39
+ this is unavoidable.
40
+
41
+ ## Register a Google client
42
+
43
+ In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials):
44
+
45
+ 1. Create an **OAuth 2.0 Client ID** of type **Web application**.
46
+ 2. Add an **Authorized redirect URI** that exactly matches the URL
47
+ regstack will receive callbacks at — by default that's
48
+ `<your base_url><api_prefix>/oauth/google/callback`. For a local
49
+ dev server with the defaults that's
50
+ `http://localhost:8000/api/auth/oauth/google/callback`.
51
+ 3. Copy the **client ID** and **client secret** out — you'll set them
52
+ on regstack next.
53
+
54
+ ## Configure regstack
55
+
56
+ ```toml
57
+ # regstack.toml
58
+ enable_oauth = true
59
+
60
+ [oauth]
61
+ google_client_id = "12345.apps.googleusercontent.com"
62
+ # google_client_secret lives in regstack.secrets.env
63
+ # google_redirect_uri = "https://your.app/api/auth/oauth/google/callback" # optional override
64
+ auto_link_verified_emails = false # security choice — see below
65
+ ```
66
+
67
+ ```bash
68
+ # regstack.secrets.env
69
+ REGSTACK_OAUTH__GOOGLE_CLIENT_SECRET=...
70
+ ```
71
+
72
+ The router is mounted only when `enable_oauth=true` AND
73
+ `google_client_id` AND `google_client_secret` are all set.
74
+
75
+ ## The account-linking decision
76
+
77
+ When a Google sign-in arrives carrying an email that already belongs to
78
+ a regstack user (created via password registration), regstack has to
79
+ choose between three policies:
80
+
81
+ | Policy | Behaviour |
82
+ |---|---|
83
+ | **Refuse** (default) | Return `?error=email_in_use` on the redirect. The user must sign in with their existing password, then link Google from `/account/me`. |
84
+ | **Auto-link verified** | If Google's `email_verified=true`, silently link the new identity to the existing user. UX win, but trusts Google's email-verified claim *forever*. |
85
+ | **Always create new** | Make a second account. |
86
+
87
+ regstack defaults to **refuse**. To opt into auto-linking — accepting
88
+ that an attacker who later acquires a recycled Gmail address could sign
89
+ in as the original regstack user — set
90
+ `oauth.auto_link_verified_emails = true`.
91
+
92
+ The full threat-model writeup is in
93
+ [`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md).
94
+
95
+ ## OAuth-only users
96
+
97
+ A Google sign-up creates a regstack user with `hashed_password = None`.
98
+ Three knock-on effects, all handled:
99
+
100
+ - **Login route** rejects password attempts on these accounts with the
101
+ same generic 401 a wrong-password attempt gets — never reveal that
102
+ an account exists but has no password.
103
+ - **`change-password` / `change-email` / `delete-account`** all need
104
+ the current password. For OAuth-only users they return 400 with a
105
+ pointer at the password-reset flow, which doubles as a "set initial
106
+ password" path.
107
+ - **`DELETE /oauth/{provider}/link`** refuses if it would remove the
108
+ user's only sign-in method (no password set, no other linked
109
+ provider). The error is `400 last sign-in method`.
110
+
111
+ ## Hooks
112
+
113
+ ```python
114
+ @regstack.on("oauth_signin_completed")
115
+ async def _track_signin(*, user, provider, mode, was_new):
116
+ if was_new:
117
+ await analytics.track("signup", {"user": user.id, "provider": provider})
118
+ else:
119
+ await analytics.track("login", {"user": user.id, "provider": provider})
120
+
121
+
122
+ @regstack.on("oauth_account_linked")
123
+ async def _notify_link(*, user, provider):
124
+ await mailer.send_link_notification(to=user.email, provider=provider)
125
+ ```
126
+
127
+ The full event list is in the
128
+ [architecture guide](architecture.md#hooks).
129
+
130
+ ## Disabling OAuth
131
+
132
+ Flip `enable_oauth = false` (or leave the credentials unset). The
133
+ router won't mount; the SSR login page won't render the button; the
134
+ `/me` panel hides the section. No other configuration changes are
135
+ required.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.2.5"
3
+ version = "0.3.0"
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"
@@ -40,6 +40,7 @@ mongo = ["pymongo>=4.9"]
40
40
  ses = ["aioboto3>=12.3"]
41
41
  sns = ["aioboto3>=12.3"]
42
42
  twilio = ["twilio>=9.0"]
43
+ oauth = ["pyjwt[crypto]>=2.8"]
43
44
  docs = [
44
45
  "sphinx>=7.3",
45
46
  "myst-parser>=3.0",
@@ -62,6 +63,8 @@ dev = [
62
63
  # All backend drivers in dev so the parametrized test suite can run.
63
64
  "pymongo>=4.9",
64
65
  "asyncpg>=0.29",
66
+ # OAuth provider tests need the crypto bits to verify ID tokens.
67
+ "pyjwt[crypto]>=2.8",
65
68
  ]
66
69
 
67
70
  [project.scripts]
@@ -0,0 +1,12 @@
1
+ from regstack.app import RegStack
2
+ from regstack.config.schema import EmailConfig, OAuthConfig, RegStackConfig, SmsConfig
3
+ from regstack.version import __version__
4
+
5
+ __all__ = [
6
+ "EmailConfig",
7
+ "OAuthConfig",
8
+ "RegStack",
9
+ "RegStackConfig",
10
+ "SmsConfig",
11
+ "__version__",
12
+ ]
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
29
29
  from jinja2 import Environment
30
30
 
31
31
  from regstack.backends.base import Backend
32
+ from regstack.oauth import OAuthRegistry
32
33
 
33
34
 
34
35
  class RegStack:
@@ -131,6 +132,8 @@ class RegStack:
131
132
  self.blacklist = self.backend.blacklist
132
133
  self.attempts = self.backend.attempts
133
134
  self.mfa_codes = self.backend.mfa_codes
135
+ self.oauth_identities = self.backend.oauth_identities
136
+ self.oauth_states = self.backend.oauth_states
134
137
 
135
138
  self.lockout = LockoutService(attempts=self.attempts, config=config, clock=self.clock)
136
139
  self.email: EmailService = email_service or build_email_service(config.email)
@@ -141,12 +144,38 @@ class RegStack:
141
144
  )
142
145
  self.hooks = HookRegistry()
143
146
  self.deps = AuthDependencies(jwt=self.jwt, users=self.users, blacklist=self.blacklist)
147
+ self.oauth = self._build_oauth_registry()
144
148
  self._template_dirs: list[Path] = list(config.extra_template_dirs)
145
149
  self._ui_env: Environment | None = None
146
150
  self._router: APIRouter | None = None
147
151
  self._ui_router: APIRouter | None = None
148
152
  self._static_files: StaticFiles | None = None
149
153
 
154
+ def _build_oauth_registry(self) -> OAuthRegistry:
155
+ """Build the OAuth registry, populated from config.
156
+
157
+ The ``regstack.oauth`` import is lazy so the package keeps
158
+ importing on a base install (no ``oauth`` extra). When
159
+ ``enable_oauth`` is off the registry is empty; the router won't
160
+ be mounted regardless.
161
+ """
162
+ from regstack.oauth import OAuthRegistry
163
+
164
+ registry = OAuthRegistry()
165
+ if not self.config.enable_oauth:
166
+ return registry
167
+ oauth_cfg = self.config.oauth
168
+ if oauth_cfg.google_client_id and oauth_cfg.google_client_secret:
169
+ from regstack.oauth.providers.google import GoogleProvider
170
+
171
+ registry.register(
172
+ GoogleProvider(
173
+ client_id=oauth_cfg.google_client_id,
174
+ client_secret=oauth_cfg.google_client_secret.get_secret_value(),
175
+ )
176
+ )
177
+ return registry
178
+
150
179
  @property
151
180
  def router(self) -> APIRouter:
152
181
  """The composite JSON ``APIRouter``.
@@ -20,6 +20,8 @@ if TYPE_CHECKING:
20
20
  BlacklistRepoProtocol,
21
21
  LoginAttemptRepoProtocol,
22
22
  MfaCodeRepoProtocol,
23
+ OAuthIdentityRepoProtocol,
24
+ OAuthStateRepoProtocol,
23
25
  PendingRepoProtocol,
24
26
  UserRepoProtocol,
25
27
  )
@@ -51,6 +53,8 @@ class Backend(ABC):
51
53
  blacklist: BlacklistRepoProtocol
52
54
  attempts: LoginAttemptRepoProtocol
53
55
  mfa_codes: MfaCodeRepoProtocol
56
+ oauth_identities: OAuthIdentityRepoProtocol
57
+ oauth_states: OAuthStateRepoProtocol
54
58
 
55
59
  # --- Lifecycle -------------------------------------------------------
56
60
 
@@ -8,6 +8,10 @@ from regstack.backends.mongo.indexes import install_indexes
8
8
  from regstack.backends.mongo.repositories.blacklist_repo import BlacklistRepo
9
9
  from regstack.backends.mongo.repositories.login_attempt_repo import LoginAttemptRepo
10
10
  from regstack.backends.mongo.repositories.mfa_code_repo import MfaCodeRepo
11
+ from regstack.backends.mongo.repositories.oauth_identity_repo import (
12
+ MongoOAuthIdentityRepo,
13
+ )
14
+ from regstack.backends.mongo.repositories.oauth_state_repo import MongoOAuthStateRepo
11
15
  from regstack.backends.mongo.repositories.pending_repo import PendingRepo
12
16
  from regstack.backends.mongo.repositories.user_repo import UserRepo
13
17
 
@@ -36,6 +40,8 @@ class MongoBackend(Backend):
36
40
  self.blacklist = BlacklistRepo(self._db, config.blacklist_collection)
37
41
  self.attempts = LoginAttemptRepo(self._db, config.login_attempt_collection)
38
42
  self.mfa_codes = MfaCodeRepo(self._db, config.mfa_code_collection, clock=clock)
43
+ self.oauth_identities = MongoOAuthIdentityRepo(self._db, config.oauth_identity_collection)
44
+ self.oauth_states = MongoOAuthStateRepo(self._db, config.oauth_state_collection)
39
45
 
40
46
  async def install_schema(self) -> None:
41
47
  await install_indexes(self._db, self.config)
@@ -67,4 +67,31 @@ async def install_indexes(db: AsyncDatabase, config: RegStackConfig) -> None:
67
67
  ]
68
68
  )
69
69
 
70
+ oauth_identities = db[config.oauth_identity_collection]
71
+ await oauth_identities.create_indexes(
72
+ [
73
+ IndexModel(
74
+ [("provider", ASCENDING), ("subject_id", ASCENDING)],
75
+ unique=True,
76
+ name="provider_subject_unique",
77
+ ),
78
+ IndexModel(
79
+ [("user_id", ASCENDING), ("provider", ASCENDING)],
80
+ unique=True,
81
+ name="user_provider_unique",
82
+ ),
83
+ ]
84
+ )
85
+
86
+ oauth_states = db[config.oauth_state_collection]
87
+ await oauth_states.create_indexes(
88
+ [
89
+ IndexModel(
90
+ [("expires_at", ASCENDING)],
91
+ expireAfterSeconds=0,
92
+ name="oauth_state_ttl",
93
+ ),
94
+ ]
95
+ )
96
+
70
97
  log.info("regstack indexes installed on database %s", db.name)
@@ -1,6 +1,10 @@
1
1
  from regstack.backends.mongo.repositories.blacklist_repo import BlacklistRepo
2
2
  from regstack.backends.mongo.repositories.login_attempt_repo import LoginAttemptRepo
3
3
  from regstack.backends.mongo.repositories.mfa_code_repo import MfaCodeRepo
4
+ from regstack.backends.mongo.repositories.oauth_identity_repo import (
5
+ MongoOAuthIdentityRepo,
6
+ )
7
+ from regstack.backends.mongo.repositories.oauth_state_repo import MongoOAuthStateRepo
4
8
  from regstack.backends.mongo.repositories.pending_repo import PendingRepo
5
9
  from regstack.backends.mongo.repositories.user_repo import UserRepo
6
10
 
@@ -8,6 +12,8 @@ __all__ = [
8
12
  "BlacklistRepo",
9
13
  "LoginAttemptRepo",
10
14
  "MfaCodeRepo",
15
+ "MongoOAuthIdentityRepo",
16
+ "MongoOAuthStateRepo",
11
17
  "PendingRepo",
12
18
  "UserRepo",
13
19
  ]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from bson import ObjectId
7
+ from pymongo.errors import DuplicateKeyError
8
+
9
+ from regstack.backends.protocols import OAuthIdentityAlreadyLinkedError
10
+ from regstack.models.oauth_identity import OAuthIdentity
11
+
12
+ if TYPE_CHECKING:
13
+ from pymongo.asynchronous.database import AsyncDatabase
14
+
15
+
16
+ class MongoOAuthIdentityRepo:
17
+ def __init__(self, db: AsyncDatabase, collection_name: str) -> None:
18
+ self._collection = db[collection_name]
19
+
20
+ async def create(self, identity: OAuthIdentity) -> OAuthIdentity:
21
+ try:
22
+ result = await self._collection.insert_one(identity.to_mongo())
23
+ except DuplicateKeyError as exc:
24
+ raise OAuthIdentityAlreadyLinkedError(
25
+ f"{identity.provider}/{identity.subject_id}"
26
+ ) from exc
27
+ identity.id = str(result.inserted_id)
28
+ return identity
29
+
30
+ async def find_by_subject(self, *, provider: str, subject_id: str) -> OAuthIdentity | None:
31
+ doc = await self._collection.find_one({"provider": provider, "subject_id": subject_id})
32
+ return self._hydrate(doc)
33
+
34
+ async def list_for_user(self, user_id: str) -> list[OAuthIdentity]:
35
+ cursor = self._collection.find({"user_id": user_id}).sort("linked_at", 1)
36
+ out: list[OAuthIdentity] = []
37
+ async for doc in cursor:
38
+ identity = self._hydrate(doc)
39
+ if identity is not None:
40
+ out.append(identity)
41
+ return out
42
+
43
+ async def delete(self, *, user_id: str, provider: str) -> bool:
44
+ result = await self._collection.delete_one({"user_id": user_id, "provider": provider})
45
+ return bool(result.deleted_count)
46
+
47
+ async def delete_by_user_id(self, user_id: str) -> int:
48
+ result = await self._collection.delete_many({"user_id": user_id})
49
+ return int(result.deleted_count)
50
+
51
+ async def touch_last_used(self, *, provider: str, subject_id: str, when: datetime) -> None:
52
+ await self._collection.update_one(
53
+ {"provider": provider, "subject_id": subject_id},
54
+ {"$set": {"last_used_at": when}},
55
+ )
56
+
57
+ @staticmethod
58
+ def _hydrate(doc: dict[str, Any] | None) -> OAuthIdentity | None:
59
+ if doc is None:
60
+ return None
61
+ if isinstance(doc.get("_id"), ObjectId):
62
+ doc["_id"] = str(doc["_id"])
63
+ return OAuthIdentity.model_validate(doc)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from regstack.models.oauth_state import OAuthState
7
+
8
+ if TYPE_CHECKING:
9
+ from pymongo.asynchronous.database import AsyncDatabase
10
+
11
+
12
+ class MongoOAuthStateRepo:
13
+ def __init__(self, db: AsyncDatabase, collection_name: str) -> None:
14
+ self._collection = db[collection_name]
15
+
16
+ async def create(self, state: OAuthState) -> None:
17
+ # We use the caller-supplied id as _id directly so consume() can
18
+ # delete by primary key without needing a separate index.
19
+ doc = state.to_mongo()
20
+ await self._collection.insert_one(doc)
21
+
22
+ async def find(self, state_id: str) -> OAuthState | None:
23
+ doc = await self._collection.find_one({"_id": state_id})
24
+ return self._hydrate(doc)
25
+
26
+ async def set_result_token(self, state_id: str, token: str) -> None:
27
+ await self._collection.update_one(
28
+ {"_id": state_id},
29
+ {"$set": {"result_token": token}},
30
+ )
31
+
32
+ async def consume(self, state_id: str) -> OAuthState | None:
33
+ doc = await self._collection.find_one_and_delete({"_id": state_id})
34
+ return self._hydrate(doc)
35
+
36
+ async def purge_expired(self, now: datetime | None = None) -> int:
37
+ cutoff = now or datetime.now(UTC)
38
+ result = await self._collection.delete_many({"expires_at": {"$lt": cutoff}})
39
+ return int(result.deleted_count)
40
+
41
+ @staticmethod
42
+ def _hydrate(doc: dict[str, Any] | None) -> OAuthState | None:
43
+ if doc is None:
44
+ return None
45
+ return OAuthState.model_validate(doc)
@@ -64,6 +64,10 @@ class PendingRepo:
64
64
  result = await self._collection.delete_many({"expires_at": {"$lt": cutoff}})
65
65
  return int(result.deleted_count)
66
66
 
67
+ async def count_unexpired(self, now: datetime | None = None) -> int:
68
+ cutoff = now or datetime.now(UTC)
69
+ return await self._collection.count_documents({"expires_at": {"$gt": cutoff}})
70
+
67
71
  @staticmethod
68
72
  def _hydrate(doc: dict[str, Any] | None) -> PendingRegistration | None:
69
73
  if doc is None: