simple-module-users 0.0.36__tar.gz → 0.0.37__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 (209) hide show
  1. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/.gitignore +1 -0
  2. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/PKG-INFO +6 -6
  3. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/pyproject.toml +6 -6
  4. simple_module_users-0.0.37/tests/_users_oauth_support.py +84 -0
  5. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_auth_screens_props.py +7 -0
  6. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_external_users.py +6 -45
  7. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_negative_authz.py +4 -1
  8. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_token_api.py +22 -1
  9. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_manager.py +42 -4
  10. simple_module_users-0.0.37/tests/test_users_bearer_scheme.py +54 -0
  11. simple_module_users-0.0.37/tests/test_users_default_role.py +82 -0
  12. simple_module_users-0.0.37/tests/test_users_oauth_signup_gate.py +63 -0
  13. simple_module_users-0.0.37/tests/test_users_oauth_unverified_link.py +85 -0
  14. simple_module_users-0.0.37/tests/test_users_register_role_durable.py +54 -0
  15. simple_module_users-0.0.37/tests/test_users_require_verification.py +104 -0
  16. simple_module_users-0.0.37/tests/test_users_seed_role_migration.py +77 -0
  17. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests-js/LoginForm.test.tsx +6 -0
  18. simple_module_users-0.0.37/tests-js/Register.test.tsx +77 -0
  19. simple_module_users-0.0.37/tests-js/registerErrors.test.ts +72 -0
  20. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/api.py +4 -2
  21. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/LoginForm.tsx +2 -2
  22. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/PasswordFields.tsx +5 -1
  23. simple_module_users-0.0.37/users/auth_local/registerErrors.ts +79 -0
  24. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/token_api.py +13 -3
  25. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/views.py +19 -3
  26. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/bootstrap.py +1 -1
  27. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/locales/en.json +10 -4
  28. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/manager.py +30 -4
  29. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/user.py +5 -4
  30. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/user_role.py +3 -2
  31. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/oauth/api.py +53 -0
  32. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Login.tsx +7 -1
  33. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Register.tsx +86 -34
  34. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/provider.py +11 -4
  35. simple_module_users-0.0.37/users/registration.py +41 -0
  36. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/token_strategy.py +4 -1
  37. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/LICENSE +0 -0
  38. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/README.md +0 -0
  39. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/package.json +0 -0
  40. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/.gitkeep +0 -0
  41. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/_demo_support.py +0 -0
  42. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/_middleware_support.py +0 -0
  43. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/_users_app_builders.py +0 -0
  44. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/conftest.py +0 -0
  45. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_access_token_model.py +0 -0
  46. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_activity_summary.py +0 -0
  47. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_admin_resend_invite.py +0 -0
  48. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_admin.py +0 -0
  49. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_admin_crud.py +0 -0
  50. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_admin_filters.py +0 -0
  51. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_auth.py +0 -0
  52. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_backend.py +0 -0
  53. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bearer_token_age.py +0 -0
  54. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bearer_token_expiry.py +0 -0
  55. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bootstrap.py +0 -0
  56. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bootstrap_resolution.py +0 -0
  57. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bulk_invite_message.py +0 -0
  58. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_cli.py +0 -0
  59. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_constants.py +0 -0
  60. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_db_adapter.py +0 -0
  61. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_demo_account.py +0 -0
  62. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_demo_guard.py +0 -0
  63. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_demo_login.py +0 -0
  64. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_edit_recent_activity.py +0 -0
  65. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_invite_flow.py +0 -0
  66. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_invite_reuse.py +0 -0
  67. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_legacy_cached_tenant.py +0 -0
  68. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_legacy_session_expiry.py +0 -0
  69. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_login_redirect_root_mount.py +0 -0
  70. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_mailer.py +0 -0
  71. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_oauth.py +0 -0
  72. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_oauth_routes.py +0 -0
  73. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_profile_password.py +0 -0
  74. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_rate_limit.py +0 -0
  75. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_remember_me.py +0 -0
  76. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_revocation_broadcast.py +0 -0
  77. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_role_model.py +0 -0
  78. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_service_admin.py +0 -0
  79. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_service_admin_crud.py +0 -0
  80. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_expiry.py +0 -0
  81. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_revocation.py +0 -0
  82. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_version_cache.py +0 -0
  83. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_version_failopen.py +0 -0
  84. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_version_hardening.py +0 -0
  85. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_sessions_revoke.py +0 -0
  86. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_settings.py +0 -0
  87. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_model.py +0 -0
  88. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_role_model.py +0 -0
  89. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_service.py +0 -0
  90. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_state.py +0 -0
  91. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_bulk_invite.py +0 -0
  92. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_deps.py +0 -0
  93. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_list_invites.py +0 -0
  94. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_login_deep_link.py +0 -0
  95. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_middleware.py +0 -0
  96. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_middleware_public_paths.py +0 -0
  97. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_middleware_resolvers.py +0 -0
  98. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_provider.py +0 -0
  99. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_setup_lock.py +0 -0
  100. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_setup_wizard.py +0 -0
  101. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_shared_props.py +0 -0
  102. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_views.py +0 -0
  103. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_views_admin.py +0 -0
  104. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests-js/EmailChipInput.test.tsx +0 -0
  105. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests-js/deriveState.test.ts +0 -0
  106. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests-js/invite-emails.test.ts +0 -0
  107. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tsconfig.json +0 -0
  108. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/__init__.py +0 -0
  109. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/__init__.py +0 -0
  110. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/api.py +0 -0
  111. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/bulk_invite.py +0 -0
  112. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/IndexFilters.tsx +0 -0
  113. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/RolesTab.tsx +0 -0
  114. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/StatusPill.tsx +0 -0
  115. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UserCards.tsx +0 -0
  116. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UserRow.tsx +0 -0
  117. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UserRowMenu.tsx +0 -0
  118. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UsersEmpty.tsx +0 -0
  119. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UsersTable.tsx +0 -0
  120. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/useUserRowActions.ts +0 -0
  121. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/user-list-item.ts +0 -0
  122. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/queries.py +0 -0
  123. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/recent_activity.py +0 -0
  124. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/resend_invite.py +0 -0
  125. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/service.py +0 -0
  126. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/user_state.py +0 -0
  127. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/views.py +0 -0
  128. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/audit.py +0 -0
  129. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/__init__.py +0 -0
  130. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/AuthField.tsx +0 -0
  131. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/AuthIntro.tsx +0 -0
  132. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/AuthStateCard.tsx +0 -0
  133. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/DemoSignIn.tsx +0 -0
  134. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/InviteSummary.tsx +0 -0
  135. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/PasswordCard.tsx +0 -0
  136. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/PreferencesCard.tsx +0 -0
  137. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  138. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/ResendVerification.tsx +0 -0
  139. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  140. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/SessionsCard.tsx +0 -0
  141. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  142. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/profile-user.ts +0 -0
  143. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/demo_api.py +0 -0
  144. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/invite_preview.py +0 -0
  145. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/rate_limit.py +0 -0
  146. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/self_account.py +0 -0
  147. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/token_preview.py +0 -0
  148. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/backend.py +0 -0
  149. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/cli.py +0 -0
  150. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/constants.py +0 -0
  151. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/contracts/__init__.py +0 -0
  152. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/contracts/events.py +0 -0
  153. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/contracts/schemas.py +0 -0
  154. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/db_adapter.py +0 -0
  155. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/demo.py +0 -0
  156. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/demo_guard.py +0 -0
  157. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/deps.py +0 -0
  158. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/exceptions.py +0 -0
  159. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/health.py +0 -0
  160. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/__init__.py +0 -0
  161. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/console.py +0 -0
  162. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/smtp.py +0 -0
  163. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/.gitkeep +0 -0
  164. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/invite.txt +0 -0
  165. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/reset_password.txt +0 -0
  166. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/verify_email.txt +0 -0
  167. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/middleware.py +0 -0
  168. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/__init__.py +0 -0
  169. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/_base.py +0 -0
  170. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/access_token.py +0 -0
  171. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/oauth_account.py +0 -0
  172. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/refresh_token.py +0 -0
  173. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/role.py +0 -0
  174. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/module.py +0 -0
  175. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/oauth/__init__.py +0 -0
  176. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/oauth/providers.py +0 -0
  177. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/.gitkeep +0 -0
  178. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/AcceptInvite.tsx +0 -0
  179. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/ForgotPassword.tsx +0 -0
  180. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Profile.tsx +0 -0
  181. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/ResetPassword.tsx +0 -0
  182. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/AddPeople.tsx +0 -0
  183. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/Edit.tsx +0 -0
  184. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/Index.tsx +0 -0
  185. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/AccountCard.tsx +0 -0
  186. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  187. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/DangerZone.tsx +0 -0
  188. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/DetailsCard.tsx +0 -0
  189. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  190. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  191. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/InviteFields.tsx +0 -0
  192. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/InviteResults.tsx +0 -0
  193. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  194. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/RolePicker.tsx +0 -0
  195. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/UserStats.tsx +0 -0
  196. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/useEditUserForm.ts +0 -0
  197. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/useUserActions.ts +0 -0
  198. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/invite-emails.ts +0 -0
  199. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/VerifyEmail.tsx +0 -0
  200. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/py.typed +0 -0
  201. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/roles_cache.py +0 -0
  202. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/session_revocation.py +0 -0
  203. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/session_version_cache.py +0 -0
  204. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/settings.py +0 -0
  205. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/setup.py +0 -0
  206. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/setup_action.py +0 -0
  207. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/shared_props.py +0 -0
  208. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/startup.py +0 -0
  209. {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/state.py +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.36
3
+ Version: 0.0.37
4
4
  Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
24
24
  Requires-Dist: aiosmtplib>=3.0
25
25
  Requires-Dist: cachetools>=5.3
26
26
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
- Requires-Dist: simple-module-auth==0.0.36
28
- Requires-Dist: simple-module-core==0.0.36
29
- Requires-Dist: simple-module-db==0.0.36
30
- Requires-Dist: simple-module-hosting==0.0.36
31
- Requires-Dist: simple-module-settings==0.0.36
27
+ Requires-Dist: simple-module-auth==0.0.37
28
+ Requires-Dist: simple-module-core==0.0.37
29
+ Requires-Dist: simple-module-db==0.0.37
30
+ Requires-Dist: simple-module-hosting==0.0.37
31
+ Requires-Dist: simple-module-settings==0.0.37
32
32
  Requires-Dist: typer>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.36"
3
+ version = "0.0.37"
4
4
  description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,11 +21,11 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.36",
25
- "simple_module_db==0.0.36",
26
- "simple_module_hosting==0.0.36",
27
- "simple_module_settings==0.0.36",
28
- "simple_module_auth==0.0.36",
24
+ "simple_module_core==0.0.37",
25
+ "simple_module_db==0.0.37",
26
+ "simple_module_hosting==0.0.37",
27
+ "simple_module_settings==0.0.37",
28
+ "simple_module_auth==0.0.37",
29
29
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
30
30
  # fields after construction (see reconfigure_cookie_transport in backend.py).
31
31
  # Bumping the major version requires re-checking those field names.
@@ -0,0 +1,84 @@
1
+ """A fake OAuth provider (no network) to drive the ``/login`` + ``/callback`` flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ import httpx
9
+ from fastapi_users.password import PasswordHelper
10
+ from sqlalchemy import select
11
+ from users.models import User
12
+
13
+
14
+ class _FakeOAuthClient:
15
+ def __init__(self, account_id: str, account_email: str) -> None:
16
+ self._account_id = account_id
17
+ self._account_email = account_email
18
+
19
+ async def get_authorization_url(self, redirect_uri: str, state: str) -> str:
20
+ return f"https://idp.example/authorize?state={state}"
21
+
22
+ async def get_access_token(self, code: str, redirect_uri: str) -> dict:
23
+ return {"access_token": "fake-token", "expires_at": None, "refresh_token": None}
24
+
25
+ async def get_id_email(self, access_token: str) -> tuple[str, str]:
26
+ return self._account_id, self._account_email
27
+
28
+
29
+ def install_fake_provider(app, account_id: str, account_email: str) -> None:
30
+ from users.oauth import OAuthProvider
31
+
32
+ app.state.users.oauth_clients["microsoft"] = OAuthProvider(
33
+ "microsoft", "Microsoft", _FakeOAuthClient(account_id, account_email)
34
+ )
35
+
36
+
37
+ def set_users_settings(app, **update) -> None:
38
+ """Change fields on the live users settings, as an admin edit would."""
39
+ settings = app.state.users.settings
40
+ app.state.users.settings = settings.model_copy(update=update)
41
+
42
+
43
+ def set_allow_signup(app, allowed: bool) -> None:
44
+ """Flip ``allow_signup`` on the live settings, as an admin edit would."""
45
+ set_users_settings(app, allow_signup=allowed)
46
+
47
+
48
+ async def find_user(app, email: str) -> User | None:
49
+ async with app.state.sm.db.session_factory() as session:
50
+ stmt = select(User).where(User.email == email)
51
+ return (await session.execute(stmt)).scalar_one_or_none()
52
+
53
+
54
+ async def seed_password_user(app, email: str, **fields) -> uuid.UUID:
55
+ """Insert an active password account for ``email``; return its id."""
56
+ user_id = uuid.uuid4()
57
+ async with app.state.sm.db.session_factory() as session:
58
+ session.add(
59
+ User(
60
+ id=user_id,
61
+ email=email,
62
+ hashed_password=PasswordHelper().hash("SecurePass1!"),
63
+ is_active=True,
64
+ **fields,
65
+ )
66
+ )
67
+ await session.commit()
68
+ return user_id
69
+
70
+
71
+ async def oauth_callback(client: httpx.AsyncClient) -> httpx.Response:
72
+ """Drive /login then /callback; return the callback response."""
73
+ login = await client.get("/api/users/auth/microsoft/login", follow_redirects=False)
74
+ assert login.status_code == 302
75
+ state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
76
+ return await client.get(
77
+ f"/api/users/auth/microsoft/callback?code=abc&state={state}",
78
+ follow_redirects=False,
79
+ )
80
+
81
+
82
+ async def run_oauth_login(client: httpx.AsyncClient) -> int:
83
+ """Drive /login then /callback; return the callback status code."""
84
+ return (await oauth_callback(client)).status_code
@@ -257,3 +257,10 @@ class TestAcceptInviteFullName:
257
257
  await session.execute(select(User).where(User.email == "joins@example.com"))
258
258
  ).scalar_one()
259
259
  assert stored.full_name == "Rob Meyer"
260
+
261
+
262
+ class TestLoginAsideProps:
263
+ @pytest.mark.anyio
264
+ async def test_keycloak_flag_is_false_without_the_module(self, anon_client):
265
+ props = _props(await anon_client.get("/users/login", headers=_INERTIA_HEADERS))
266
+ assert props["keycloak_installed"] is False
@@ -11,9 +11,9 @@ Covers the feature added on top of the OAuth/OIDC login flow:
11
11
  from __future__ import annotations
12
12
 
13
13
  import uuid
14
- from urllib.parse import parse_qs, urlparse
15
14
 
16
15
  import pytest
16
+ from _users_oauth_support import install_fake_provider, run_oauth_login, set_allow_signup
17
17
  from fastapi_users.password import PasswordHelper
18
18
  from sqlalchemy import select
19
19
  from users.models import Role, User
@@ -21,46 +21,6 @@ from users.models import Role, User
21
21
  _pw = PasswordHelper()
22
22
 
23
23
 
24
- # ---------------------------------------------------------------------------
25
- # Fake OAuth provider (no network) to drive the /login + /callback flow
26
- # ---------------------------------------------------------------------------
27
-
28
-
29
- class _FakeOAuthClient:
30
- def __init__(self, account_id: str, account_email: str) -> None:
31
- self._account_id = account_id
32
- self._account_email = account_email
33
-
34
- async def get_authorization_url(self, redirect_uri: str, state: str) -> str:
35
- return f"https://idp.example/authorize?state={state}"
36
-
37
- async def get_access_token(self, code: str, redirect_uri: str) -> dict:
38
- return {"access_token": "fake-token", "expires_at": None, "refresh_token": None}
39
-
40
- async def get_id_email(self, access_token: str) -> tuple[str, str]:
41
- return self._account_id, self._account_email
42
-
43
-
44
- def _install_fake_provider(app, account_id: str, account_email: str) -> None:
45
- from users.oauth import OAuthProvider
46
-
47
- app.state.users.oauth_clients["microsoft"] = OAuthProvider(
48
- "microsoft", "Microsoft", _FakeOAuthClient(account_id, account_email)
49
- )
50
-
51
-
52
- async def _run_oauth_login(client) -> int:
53
- """Drive /login then /callback; return the callback status code."""
54
- login = await client.get("/api/users/auth/microsoft/login", follow_redirects=False)
55
- assert login.status_code == 302
56
- state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
57
- cb = await client.get(
58
- f"/api/users/auth/microsoft/callback?code=abc&state={state}",
59
- follow_redirects=False,
60
- )
61
- return cb.status_code
62
-
63
-
64
24
  async def _get_user_by_email(db, email: str) -> User:
65
25
  return (await db.execute(select(User).where(User.email == email))).scalar_one()
66
26
 
@@ -72,9 +32,10 @@ async def _get_user_by_email(db, email: str) -> User:
72
32
 
73
33
  @pytest.mark.anyio
74
34
  async def test_new_oauth_login_creates_external_user(users_app, anon_client, users_db):
75
- _install_fake_provider(users_app, "ms-oid-1", "sso-new@example.com")
35
+ set_allow_signup(users_app, True) # creating an account via OAuth is a signup (#543)
36
+ install_fake_provider(users_app, "ms-oid-1", "sso-new@example.com")
76
37
 
77
- assert await _run_oauth_login(anon_client) == 303
38
+ assert await run_oauth_login(anon_client) == 303
78
39
 
79
40
  user = await _get_user_by_email(users_db, "sso-new@example.com")
80
41
  assert user.is_external is True
@@ -96,8 +57,8 @@ async def test_oauth_login_links_existing_password_user_stays_local(
96
57
  users_db.add(existing)
97
58
  await users_db.commit()
98
59
 
99
- _install_fake_provider(users_app, "ms-oid-2", "local@example.com")
100
- assert await _run_oauth_login(anon_client) == 303
60
+ install_fake_provider(users_app, "ms-oid-2", "local@example.com")
61
+ assert await run_oauth_login(anon_client) == 303
101
62
 
102
63
  user = await _get_user_by_email(users_db, "local@example.com")
103
64
  assert user.is_external is False
@@ -17,7 +17,10 @@ import uuid
17
17
 
18
18
  import pytest
19
19
 
20
- _FAKE_ID = uuid.uuid4()
20
+ # Fixed, not ``uuid4()``: the id lands in the parametrize ids, and pytest-xdist
21
+ # refuses to run when its workers collect differently-named tests. The request
22
+ # must be refused before any lookup, so which id it names does not matter.
23
+ _FAKE_ID = uuid.UUID("00000000-0000-4000-8000-000000000000")
21
24
  _PROTECTED_ENDPOINTS: tuple[tuple[str, str, dict | None], ...] = (
22
25
  # users — admin sub-router
23
26
  ("GET", "/api/users/admin", None),
@@ -71,6 +71,27 @@ class TestTokenLogin:
71
71
  )
72
72
  assert resp.status_code == 401
73
73
 
74
+ @pytest.mark.anyio
75
+ async def test_legacy_hash_is_upgraded_on_token_login(self, anon_client, users_db):
76
+ from pwdlib.hashers.bcrypt import BcryptHasher
77
+
78
+ legacy = BcryptHasher().hash("SecurePass1!")
79
+ assert legacy.startswith("$2")
80
+ user = await _seed_user(users_db, email="legacy@example.com")
81
+ user.hashed_password = legacy
82
+ await users_db.commit()
83
+
84
+ for _ in range(2): # second login proves the upgraded hash still verifies
85
+ resp = await anon_client.post(
86
+ "/api/users/auth/token",
87
+ json={"email": "legacy@example.com", "password": "SecurePass1!"},
88
+ )
89
+ assert resp.status_code == 200
90
+
91
+ await users_db.refresh(user)
92
+ assert user.hashed_password != legacy
93
+ assert user.hashed_password.startswith("$argon2")
94
+
74
95
  @pytest.mark.anyio
75
96
  async def test_valid_credentials_returns_token_pair(self, anon_client, users_db):
76
97
  await _seed_user(users_db)
@@ -80,7 +101,7 @@ class TestTokenLogin:
80
101
  )
81
102
  assert resp.status_code == 200
82
103
  body = resp.json()
83
- assert body["token_type"] == "bearer"
104
+ assert body["token_type"] == "Bearer"
84
105
  assert body["access_token"]
85
106
  assert body["refresh_token"]
86
107
  assert body["expires_in"] > 0
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import uuid
6
6
  from datetime import UTC, datetime
7
- from unittest.mock import AsyncMock, MagicMock
7
+ from unittest.mock import AsyncMock, MagicMock, patch
8
8
 
9
9
  import pytest
10
10
  from fastapi_users import exceptions
@@ -51,6 +51,8 @@ def fake_user():
51
51
  def fake_user_db():
52
52
  db = MagicMock()
53
53
  db.update = AsyncMock(return_value=None)
54
+ db.session.flush = AsyncMock()
55
+ db.session.commit = AsyncMock()
54
56
  return db
55
57
 
56
58
 
@@ -125,17 +127,53 @@ class TestOnAfterRegister:
125
127
  # Pre-verified so the manager doesn't try to send a verify email.
126
128
  fake_user.is_verified = True
127
129
 
128
- await manager.on_after_register(fake_user, request)
130
+ with patch("users.manager.attach_default_role", AsyncMock()):
131
+ await manager.on_after_register(fake_user, request)
129
132
 
130
133
  (event,) = bus.assert_published(UserRegistered)
131
134
  assert event.user_id == fake_user.id
132
135
  assert event.email == fake_user.email
133
136
 
134
137
  @pytest.mark.anyio
135
- async def test_without_request_skips_publish(self, manager, fake_user):
136
- """CLI/bootstrap flows pass request=None — must not crash."""
138
+ async def test_request_bound_registration_gets_the_user_role(
139
+ self, manager, fake_user, fake_user_db
140
+ ):
141
+ """GH #488: a signup with no role holds no permissions at all."""
142
+ from users.models import UserRole
143
+
144
+ request = MagicMock()
145
+ request.state = MagicMock(spec=[]) # a local signup, not OAuth
146
+ request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
147
+ fake_user.is_verified = True
148
+ role = MagicMock(id=uuid.uuid4())
149
+ fake_user_db.session.execute = AsyncMock(
150
+ return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=role))
151
+ )
152
+ await manager.on_after_register(fake_user, request)
153
+
154
+ (added,) = fake_user_db.session.add.call_args.args
155
+ assert isinstance(added, UserRole)
156
+ assert (added.user_id, added.role_id) == (fake_user.id, role.id)
157
+
158
+ @pytest.mark.anyio
159
+ async def test_a_deleted_user_role_is_not_recreated(self, manager, fake_user, fake_user_db):
160
+ """An admin who removed the role meant it; a public signup must not undo that."""
161
+ request = MagicMock()
162
+ request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
163
+ fake_user.is_verified = True
164
+ fake_user_db.session.execute = AsyncMock(
165
+ return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None))
166
+ )
167
+ await manager.on_after_register(fake_user, request)
168
+ fake_user_db.session.add.assert_not_called()
169
+
170
+ @pytest.mark.anyio
171
+ async def test_without_request_skips_publish(self, manager, fake_user, fake_user_db):
172
+ """CLI/bootstrap and admin flows pass request=None — no crash, no role:
173
+ the admin picks the roles there, and may pick none."""
137
174
  fake_user.is_verified = True
138
175
  await manager.on_after_register(fake_user, None)
176
+ fake_user_db.session.add.assert_not_called()
139
177
 
140
178
 
141
179
  class TestOnAfterForgotPassword:
@@ -0,0 +1,54 @@
1
+ """The ``Authorization`` scheme is case-insensitive (RFC 9110 §11.1, GH #533).
2
+
3
+ ``/auth/token`` used to answer ``token_type="bearer"``, and a client that built
4
+ its header from that value sent ``bearer <token>`` — which the provider refused.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+ from starlette.requests import Request
11
+
12
+ _TOKEN = "/api/users/auth/token"
13
+
14
+
15
+ def _request(app, header: str) -> Request:
16
+ return Request(
17
+ {
18
+ "type": "http",
19
+ "method": "GET",
20
+ "path": "/api/anything",
21
+ "headers": [(b"authorization", header.encode())],
22
+ "session": {},
23
+ "app": app,
24
+ }
25
+ )
26
+
27
+
28
+ @pytest.mark.anyio
29
+ @pytest.mark.parametrize("scheme", ["Bearer", "bearer", "BEARER", " bearer "])
30
+ async def test_any_scheme_casing_authenticates(users_app, anon_client, scheme):
31
+ from users.provider import UsersAuthProvider
32
+
33
+ resp = await anon_client.post(
34
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
35
+ )
36
+ assert resp.status_code == 200, resp.text
37
+ body = resp.json()
38
+ assert body["token_type"] == "Bearer"
39
+
40
+ provider = UsersAuthProvider()
41
+ request = _request(users_app, f"{scheme} {body['access_token']}")
42
+ assert provider.is_bearer_request(request)
43
+ ctx = await provider.resolve_user(request)
44
+ assert ctx is not None
45
+ assert ctx.email == "admin@example.com"
46
+
47
+
48
+ @pytest.mark.anyio
49
+ async def test_another_scheme_is_not_a_bearer_request(users_app):
50
+ from users.provider import UsersAuthProvider
51
+
52
+ request = _request(users_app, "Basic dXNlcjpwYXNz")
53
+ assert not UsersAuthProvider().is_bearer_request(request)
54
+ assert await UsersAuthProvider().resolve_user(request) is None
@@ -0,0 +1,82 @@
1
+ """Which registrations get the standard ``user`` role (GH #488 review).
2
+
3
+ OAuth provisioning is not gated by ``allow_signup``, so the role it attaches
4
+ must be: with signups closed, an account any provider user can create holds no
5
+ role until an admin grants one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from types import SimpleNamespace
12
+ from unittest.mock import AsyncMock, MagicMock
13
+
14
+ import pytest
15
+ from users.constants import OAUTH_REGISTRATION_REQUEST_FLAG
16
+ from users.manager import UserManager
17
+ from users.models import UserRole
18
+ from users.settings import UsersSettings
19
+
20
+ _ROLE_ID = uuid.uuid4()
21
+
22
+
23
+ def _manager(*, allow_signup: bool) -> UserManager:
24
+ settings = UsersSettings(
25
+ verification_token_secret="test-verify-secret-at-least-32-bytes-long",
26
+ reset_password_token_secret="test-reset-secret-at-least-32-bytes-long",
27
+ allow_signup=allow_signup,
28
+ )
29
+ user_db = MagicMock()
30
+ user_db.update = AsyncMock()
31
+ user_db.session.flush = AsyncMock()
32
+ user_db.session.commit = AsyncMock()
33
+ user_db.session.execute = AsyncMock(
34
+ return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=MagicMock(id=_ROLE_ID)))
35
+ )
36
+ return UserManager(user_db, MagicMock(send_verification=AsyncMock()), settings)
37
+
38
+
39
+ def _request(*, oauth: bool) -> MagicMock:
40
+ request = MagicMock()
41
+ request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
42
+ request.state = SimpleNamespace(**({OAUTH_REGISTRATION_REQUEST_FLAG: True} if oauth else {}))
43
+ return request
44
+
45
+
46
+ def _user() -> MagicMock:
47
+ return MagicMock(id=uuid.uuid4(), email="new@example.com", is_verified=True)
48
+
49
+
50
+ def _added_roles(manager: UserManager) -> list[UserRole]:
51
+ return [
52
+ c.args[0]
53
+ for c in manager.user_db.session.add.call_args_list
54
+ if isinstance(c.args[0], UserRole)
55
+ ]
56
+
57
+
58
+ @pytest.mark.anyio
59
+ async def test_oauth_provisioning_with_signup_closed_gets_no_role():
60
+ manager = _manager(allow_signup=False)
61
+ await manager.on_after_register(_user(), _request(oauth=True))
62
+ assert _added_roles(manager) == []
63
+
64
+
65
+ @pytest.mark.anyio
66
+ async def test_oauth_provisioning_with_signup_open_gets_the_user_role():
67
+ manager = _manager(allow_signup=True)
68
+ user = _user()
69
+ await manager.on_after_register(user, _request(oauth=True))
70
+ (link,) = _added_roles(manager)
71
+ assert (link.user_id, link.role_id) == (user.id, _ROLE_ID)
72
+
73
+
74
+ @pytest.mark.anyio
75
+ async def test_local_registration_gets_the_user_role():
76
+ # /register itself 404s while signup is closed, so reaching the hook
77
+ # means the operator opened it.
78
+ manager = _manager(allow_signup=True)
79
+ user = _user()
80
+ await manager.on_after_register(user, _request(oauth=False))
81
+ (link,) = _added_roles(manager)
82
+ assert (link.user_id, link.role_id) == (user.id, _ROLE_ID)
@@ -0,0 +1,63 @@
1
+ """OAuth sign-in creates an account only when ``allow_signup`` is on (GH #543).
2
+
3
+ With signups closed, an operator who enables SSO for staff must not open a
4
+ signup to the provider's whole user base. Accounts that already exist — linked
5
+ by OAuth account or associated by email — still sign in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ from _users_oauth_support import (
12
+ find_user,
13
+ install_fake_provider,
14
+ oauth_callback,
15
+ seed_password_user,
16
+ set_allow_signup,
17
+ )
18
+
19
+
20
+ @pytest.mark.anyio
21
+ async def test_signup_closed_refuses_a_new_identity(users_app, anon_client):
22
+ install_fake_provider(users_app, "ms-new", "stranger@example.com")
23
+
24
+ resp = await oauth_callback(anon_client)
25
+
26
+ assert resp.status_code == 403
27
+ assert resp.json()["detail"] == "OAUTH_SIGNUP_DISABLED"
28
+ assert users_app.state.users.settings.cookie_name not in resp.cookies
29
+ assert await find_user(users_app, "stranger@example.com") is None
30
+
31
+
32
+ @pytest.mark.anyio
33
+ async def test_signup_open_creates_the_account(users_app, anon_client):
34
+ set_allow_signup(users_app, True)
35
+ install_fake_provider(users_app, "ms-new", "newcomer@example.com")
36
+
37
+ assert (await oauth_callback(anon_client)).status_code == 303
38
+ user = await find_user(users_app, "newcomer@example.com")
39
+ assert user is not None
40
+ assert user.is_external is True
41
+
42
+
43
+ @pytest.mark.anyio
44
+ async def test_signup_closed_still_associates_an_existing_email(users_app, anon_client):
45
+ await seed_password_user(users_app, "staff@example.com", is_verified=True)
46
+ install_fake_provider(users_app, "ms-staff", "staff@example.com")
47
+
48
+ assert (await oauth_callback(anon_client)).status_code == 303
49
+
50
+
51
+ @pytest.mark.anyio
52
+ async def test_signup_closed_still_signs_in_a_linked_oauth_account(users_app, anon_client):
53
+ """Matched by the linked OAuth account even when the provider's email moved."""
54
+ set_allow_signup(users_app, True)
55
+ install_fake_provider(users_app, "ms-linked", "before@example.com")
56
+ assert (await oauth_callback(anon_client)).status_code == 303
57
+
58
+ set_allow_signup(users_app, False)
59
+ anon_client.cookies.clear()
60
+ install_fake_provider(users_app, "ms-linked", "after@example.com")
61
+
62
+ assert (await oauth_callback(anon_client)).status_code == 303
63
+ assert await find_user(users_app, "after@example.com") is None
@@ -0,0 +1,85 @@
1
+ """OAuth never links by email to an unverified password account (GH #547).
2
+
3
+ With ``require_verification`` off anyone can register ``victim@corp.com`` with
4
+ a password and sign in. If the real owner later signs in with SSO, associating
5
+ by email would hand them — and the attacker — one shared account.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import UTC, datetime
12
+
13
+ import pytest
14
+ from _users_oauth_support import (
15
+ install_fake_provider,
16
+ oauth_callback,
17
+ seed_password_user,
18
+ set_allow_signup,
19
+ )
20
+ from fastapi_users.password import PasswordHelper
21
+ from sqlalchemy import select
22
+ from users.models import OAuthAccount, User
23
+
24
+ _EMAIL = "victim@example.com"
25
+
26
+
27
+ async def _links(app, user_id: uuid.UUID) -> list[OAuthAccount]:
28
+ async with app.state.sm.db.session_factory() as session:
29
+ stmt = select(OAuthAccount).where(OAuthAccount.user_id == user_id)
30
+ return list((await session.execute(stmt)).scalars())
31
+
32
+
33
+ @pytest.mark.anyio
34
+ @pytest.mark.parametrize("allow_signup", [True, False])
35
+ async def test_unverified_password_account_is_not_linked(users_app, anon_client, allow_signup):
36
+ set_allow_signup(users_app, allow_signup)
37
+ user_id = await seed_password_user(users_app, _EMAIL, is_verified=False)
38
+ install_fake_provider(users_app, "ms-victim", _EMAIL)
39
+
40
+ resp = await oauth_callback(anon_client)
41
+
42
+ assert resp.status_code == 403
43
+ assert resp.json()["detail"] == "OAUTH_ACCOUNT_NOT_VERIFIED"
44
+ assert users_app.state.users.settings.cookie_name not in resp.cookies
45
+ assert await _links(users_app, user_id) == []
46
+
47
+
48
+ @pytest.mark.anyio
49
+ async def test_verified_account_links(users_app, anon_client):
50
+ user_id = await seed_password_user(users_app, _EMAIL, is_verified=True)
51
+ install_fake_provider(users_app, "ms-owner", _EMAIL)
52
+
53
+ assert (await oauth_callback(anon_client)).status_code == 303
54
+ assert [a.account_id for a in await _links(users_app, user_id)] == ["ms-owner"]
55
+
56
+
57
+ @pytest.mark.anyio
58
+ async def test_pending_invite_links(users_app, anon_client):
59
+ """An invite's password is random and unknown, so nobody else holds the account."""
60
+ user_id = await seed_password_user(
61
+ users_app, _EMAIL, is_verified=False, invited_at=datetime.now(UTC)
62
+ )
63
+ install_fake_provider(users_app, "ms-invitee", _EMAIL)
64
+
65
+ assert (await oauth_callback(anon_client)).status_code == 303
66
+ assert len(await _links(users_app, user_id)) == 1
67
+
68
+
69
+ @pytest.mark.anyio
70
+ async def test_already_linked_account_signs_in(users_app, anon_client):
71
+ """Found by its OAuth link, not by email — verification is not asked for."""
72
+ set_allow_signup(users_app, True)
73
+ install_fake_provider(users_app, "ms-linked", "linked@example.com")
74
+ assert (await oauth_callback(anon_client)).status_code == 303
75
+ async with users_app.state.sm.db.session_factory() as session:
76
+ user = (
77
+ await session.execute(select(User).where(User.email == "linked@example.com"))
78
+ ).scalar_one()
79
+ user.is_verified = False
80
+ user.hashed_password = PasswordHelper().hash("SecurePass1!")
81
+ user.is_external = False
82
+ await session.commit()
83
+
84
+ anon_client.cookies.clear()
85
+ assert (await oauth_callback(anon_client)).status_code == 303