simple-module-users 0.0.40__tar.gz → 0.0.41__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 (269) hide show
  1. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/PKG-INFO +7 -7
  2. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/package.json +1 -1
  3. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/pyproject.toml +7 -7
  4. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bearer_token_expiry.py +28 -5
  5. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_remember_me.py +27 -4
  6. simple_module_users-0.0.41/tests/test_users_me_credentials.py +46 -0
  7. simple_module_users-0.0.41/tests/test_users_oauth_error_responses.py +111 -0
  8. simple_module_users-0.0.41/tests/test_users_oauth_login_errors.py +51 -0
  9. simple_module_users-0.0.41/tests/test_users_refresh_family_max_age.py +178 -0
  10. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_refresh_reuse.py +1 -1
  11. simple_module_users-0.0.41/tests/test_users_reset_password_retry.py +40 -0
  12. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_token_pruning.py +3 -1
  13. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_Login.test.tsx +16 -0
  14. simple_module_users-0.0.41/tests-js/users_ResetPassword.test.tsx +114 -0
  15. simple_module_users-0.0.41/tests-js/users_admin_error_details.test.tsx +112 -0
  16. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/useUserRowActions.ts +10 -4
  17. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/api.py +3 -3
  18. simple_module_users-0.0.41/users/auth_local/resetPasswordError.ts +13 -0
  19. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/token_api.py +22 -5
  20. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/views.py +3 -0
  21. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/constants.py +2 -0
  22. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/db_adapter.py +24 -3
  23. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/deps.py +17 -0
  24. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/locales/de.json +14 -1
  25. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/locales/en.json +14 -1
  26. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/oauth/api.py +19 -24
  27. simple_module_users-0.0.41/users/oauth/errors.py +27 -0
  28. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Login.tsx +11 -1
  29. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/ResetPassword.tsx +3 -1
  30. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/DangerZone.tsx +3 -3
  31. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/useEditUserForm.ts +16 -8
  32. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/useUserActions.ts +6 -4
  33. simple_module_users-0.0.41/users/pages/failureMessage.ts +41 -0
  34. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/settings.py +4 -0
  35. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/token_storage.py +26 -3
  36. simple_module_users-0.0.40/users/pages/failureMessage.ts +0 -16
  37. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/.gitignore +0 -0
  38. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/LICENSE +0 -0
  39. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/README.md +0 -0
  40. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/.gitkeep +0 -0
  41. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_demo_support.py +0 -0
  42. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_middleware_support.py +0 -0
  43. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_users_app_builders.py +0 -0
  44. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_users_oauth_support.py +0 -0
  45. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/conftest.py +0 -0
  46. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_account.py +0 -0
  47. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_credentials.py +0 -0
  48. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_guard.py +0 -0
  49. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_login.py +0 -0
  50. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_admin_resend_invite.py +0 -0
  51. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_bulk_invite.py +0 -0
  52. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_bulk_invite_message.py +0 -0
  53. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_invite_flow.py +0 -0
  54. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_invite_reuse.py +0 -0
  55. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_list_invites.py +0 -0
  56. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_admin_unlock.py +0 -0
  57. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_checkpoint.py +0 -0
  58. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_columns.py +0 -0
  59. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_enumeration.py +0 -0
  60. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_service.py +0 -0
  61. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_login_lockout.py +0 -0
  62. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_access_token_model.py +0 -0
  63. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_activity_summary.py +0 -0
  64. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin.py +0 -0
  65. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin_crud.py +0 -0
  66. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin_filters.py +0 -0
  67. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin_pagination_bounds.py +0 -0
  68. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_auth.py +0 -0
  69. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_auth_screens_props.py +0 -0
  70. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_backend.py +0 -0
  71. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bearer_token_age.py +0 -0
  72. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bootstrap.py +0 -0
  73. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bootstrap_resolution.py +0 -0
  74. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_cli.py +0 -0
  75. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_constants.py +0 -0
  76. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_db_adapter.py +0 -0
  77. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_edit_recent_activity.py +0 -0
  78. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_external_users.py +0 -0
  79. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_legacy_cached_tenant.py +0 -0
  80. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_legacy_session_expiry.py +0 -0
  81. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_login_redirect_root_mount.py +0 -0
  82. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_mailer.py +0 -0
  83. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_negative_authz.py +0 -0
  84. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_oauth.py +0 -0
  85. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_oauth_routes.py +0 -0
  86. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_profile_password.py +0 -0
  87. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_rate_limit.py +0 -0
  88. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_revocation_broadcast.py +0 -0
  89. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_role_model.py +0 -0
  90. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_service_admin.py +0 -0
  91. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_service_admin_crud.py +0 -0
  92. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_expiry.py +0 -0
  93. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_revocation.py +0 -0
  94. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_atomic.py +0 -0
  95. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_cache.py +0 -0
  96. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_failopen.py +0 -0
  97. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_hardening.py +0 -0
  98. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_sessions_revoke.py +0 -0
  99. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_settings.py +0 -0
  100. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_token_api.py +0 -0
  101. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_model.py +0 -0
  102. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_role_model.py +0 -0
  103. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_service.py +0 -0
  104. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_state.py +0 -0
  105. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_effective_permissions.py +0 -0
  106. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_privilege_guards.py +0 -0
  107. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_role_grant_rules.py +0 -0
  108. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_session_revocation.py +0 -0
  109. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_bearer_scheme.py +0 -0
  110. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_default_role.py +0 -0
  111. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_deletion_events.py +0 -0
  112. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_deps.py +0 -0
  113. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_directory.py +0 -0
  114. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_domain_errors.py +0 -0
  115. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_login_deep_link.py +0 -0
  116. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_login_session_rotation.py +0 -0
  117. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_mailer_core_sender.py +0 -0
  118. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_manager.py +0 -0
  119. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_middleware.py +0 -0
  120. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_middleware_public_paths.py +0 -0
  121. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_middleware_resolvers.py +0 -0
  122. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_email_claim.py +0 -0
  123. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_provider_identity.py +0 -0
  124. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_signup_gate.py +0 -0
  125. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_unverified_link.py +0 -0
  126. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_password_change_keeps_api.py +0 -0
  127. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_provider.py +0 -0
  128. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_register_role_durable.py +0 -0
  129. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_register_single_connection.py +0 -0
  130. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_require_verification.py +0 -0
  131. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_reset_link_origin.py +0 -0
  132. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_seed_role_migration.py +0 -0
  133. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_setup_lock.py +0 -0
  134. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_setup_wizard.py +0 -0
  135. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_shared_props.py +0 -0
  136. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_startup_role.py +0 -0
  137. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_token_hash_migration.py +0 -0
  138. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_token_storage.py +0 -0
  139. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_update_schema.py +0 -0
  140. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_views.py +0 -0
  141. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_views_admin.py +0 -0
  142. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/AccountCardLocked.test.tsx +0 -0
  143. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/EmailChipInput.test.tsx +0 -0
  144. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/LoginForm.test.tsx +0 -0
  145. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/deriveState.test.ts +0 -0
  146. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/invite-emails.test.ts +0 -0
  147. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/registerErrors.test.ts +0 -0
  148. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_Register.test.tsx +0 -0
  149. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_VerifyEmail.test.tsx +0 -0
  150. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_actions.test.tsx +0 -0
  151. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_fixtures.ts +0 -0
  152. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_profile_cards.test.tsx +0 -0
  153. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tsconfig.json +0 -0
  154. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/__init__.py +0 -0
  155. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/__init__.py +0 -0
  156. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/api.py +0 -0
  157. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/IndexFilters.tsx +0 -0
  158. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/RolesTab.tsx +0 -0
  159. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/StatusPill.tsx +0 -0
  160. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UserCards.tsx +0 -0
  161. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UserRow.tsx +0 -0
  162. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UserRowMenu.tsx +0 -0
  163. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UsersEmpty.tsx +0 -0
  164. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UsersTable.tsx +0 -0
  165. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/user-list-item.ts +0 -0
  166. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/events.py +0 -0
  167. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/links.py +0 -0
  168. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/queries.py +0 -0
  169. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/recent_activity.py +0 -0
  170. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/safety.py +0 -0
  171. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/service.py +0 -0
  172. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/user_state.py +0 -0
  173. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/views.py +0 -0
  174. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/audit.py +0 -0
  175. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/__init__.py +0 -0
  176. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/AuthField.tsx +0 -0
  177. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/AuthIntro.tsx +0 -0
  178. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/AuthStateCard.tsx +0 -0
  179. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/DemoSignIn.tsx +0 -0
  180. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/InviteSummary.tsx +0 -0
  181. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/LoginForm.tsx +0 -0
  182. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/PasswordCard.tsx +0 -0
  183. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/PasswordFields.tsx +0 -0
  184. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/PreferencesCard.tsx +0 -0
  185. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  186. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/ResendVerification.tsx +0 -0
  187. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  188. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/SessionsCard.tsx +0 -0
  189. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  190. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/profile-user.ts +0 -0
  191. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/lockout/__init__.py +0 -0
  192. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/lockout/attempt.py +0 -0
  193. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/rate_limit.py +0 -0
  194. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/registerErrors.ts +0 -0
  195. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/self_account.py +0 -0
  196. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/token_preview.py +0 -0
  197. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/backend.py +0 -0
  198. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/bootstrap.py +0 -0
  199. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/cli.py +0 -0
  200. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/__init__.py +0 -0
  201. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/events.py +0 -0
  202. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/roles.py +0 -0
  203. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/schemas.py +0 -0
  204. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/demo/__init__.py +0 -0
  205. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/demo/api.py +0 -0
  206. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/demo/guard.py +0 -0
  207. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/directory.py +0 -0
  208. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/errors.py +0 -0
  209. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/exceptions.py +0 -0
  210. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/__init__.py +0 -0
  211. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/bulk.py +0 -0
  212. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/preview.py +0 -0
  213. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/resend.py +0 -0
  214. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/__init__.py +0 -0
  215. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templated.py +0 -0
  216. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/.gitkeep +0 -0
  217. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/invite.txt +0 -0
  218. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/reset_password.txt +0 -0
  219. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/verify_email.txt +0 -0
  220. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/manager.py +0 -0
  221. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/middleware.py +0 -0
  222. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/__init__.py +0 -0
  223. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/_base.py +0 -0
  224. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/access_token.py +0 -0
  225. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/oauth_account.py +0 -0
  226. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/refresh_token.py +0 -0
  227. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/role.py +0 -0
  228. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/user.py +0 -0
  229. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/user_role.py +0 -0
  230. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/module.py +0 -0
  231. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/oauth/__init__.py +0 -0
  232. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/oauth/providers.py +0 -0
  233. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/.gitkeep +0 -0
  234. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/AcceptInvite.tsx +0 -0
  235. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/ForgotPassword.tsx +0 -0
  236. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Profile.tsx +0 -0
  237. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Register.tsx +0 -0
  238. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/AddPeople.tsx +0 -0
  239. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/Edit.tsx +0 -0
  240. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/Index.tsx +0 -0
  241. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/AccountCard.tsx +0 -0
  242. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  243. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/DetailsCard.tsx +0 -0
  244. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  245. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  246. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/InviteFields.tsx +0 -0
  247. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/InviteResults.tsx +0 -0
  248. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/MailerBanner.tsx +0 -0
  249. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  250. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/RolePicker.tsx +0 -0
  251. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/UserStats.tsx +0 -0
  252. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/useAddPeople.ts +0 -0
  253. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/invite-emails.ts +0 -0
  254. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/VerifyEmail.tsx +0 -0
  255. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/api.generated.ts +0 -0
  256. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/api.ts +0 -0
  257. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/signIn.ts +0 -0
  258. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/provider.py +0 -0
  259. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/py.typed +0 -0
  260. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/registration.py +0 -0
  261. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/roles_cache.py +0 -0
  262. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/session_revocation.py +0 -0
  263. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/session_version_cache.py +0 -0
  264. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/setup.py +0 -0
  265. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/setup_action.py +0 -0
  266. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/shared_props.py +0 -0
  267. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/startup.py +0 -0
  268. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/state.py +0 -0
  269. {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/token_strategy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.40
3
+ Version: 0.0.41
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
@@ -23,12 +23,12 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: cachetools<8,>=5.3
25
25
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
26
- Requires-Dist: simple-module-auth==0.0.40
27
- Requires-Dist: simple-module-core==0.0.40
28
- Requires-Dist: simple-module-db==0.0.40
29
- Requires-Dist: simple-module-hosting==0.0.40
30
- Requires-Dist: simple-module-mail==0.0.40
31
- Requires-Dist: simple-module-settings==0.0.40
26
+ Requires-Dist: simple-module-auth==0.0.41
27
+ Requires-Dist: simple-module-core==0.0.41
28
+ Requires-Dist: simple-module-db==0.0.41
29
+ Requires-Dist: simple-module-hosting==0.0.41
30
+ Requires-Dist: simple-module-mail==0.0.41
31
+ Requires-Dist: simple-module-settings==0.0.41
32
32
  Requires-Dist: typer<0.28,>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/users",
3
- "version": "0.0.40",
3
+ "version": "0.0.41",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Users module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.40"
3
+ version = "0.0.41"
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,14 +21,14 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.40",
25
- "simple_module_db==0.0.40",
26
- "simple_module_hosting==0.0.40",
27
- "simple_module_settings==0.0.40",
28
- "simple_module_auth==0.0.40",
24
+ "simple_module_core==0.0.41",
25
+ "simple_module_db==0.0.41",
26
+ "simple_module_hosting==0.0.41",
27
+ "simple_module_settings==0.0.41",
28
+ "simple_module_auth==0.0.41",
29
29
  # Sends through the framework mail sender; `mail` owns the SMTP transport
30
30
  # (GH #496), so an upgrade keeps delivering mail.
31
- "simple_module_mail==0.0.40",
31
+ "simple_module_mail==0.0.41",
32
32
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
33
33
  # fields after construction (see reconfigure_cookie_transport in backend.py).
34
34
  # Bumping the major version requires re-checking those field names.
@@ -32,6 +32,30 @@ _ME = "/api/users/me"
32
32
  _CREDS = {"username": "admin@example.com", "password": "AdminPass1!"}
33
33
 
34
34
 
35
+ async def _cookie_strategy_user(app, client):
36
+ """Resolve the ``sm_auth`` cookie through the cookie strategy itself.
37
+
38
+ ``/api/users/me`` now resolves the request principal (session or bearer,
39
+ #658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row
40
+ the way ``fastapi_users.current_user`` does instead.
41
+ """
42
+ from users.backend import build_strategy
43
+ from users.db_adapter import PruningAccessTokenDatabase
44
+ from users.models import UserAccessToken
45
+
46
+ async with app.state.sm.db.session_factory() as session:
47
+
48
+ class _Manager:
49
+ def parse_id(self, value):
50
+ return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
51
+
52
+ async def get(self, user_id):
53
+ return await session.get(User, user_id)
54
+
55
+ strategy = build_strategy(PruningAccessTokenDatabase(session, UserAccessToken), 900)
56
+ return await strategy.read_token(client.cookies.get("sm_auth"), _Manager())
57
+
58
+
35
59
  async def _tokens(app) -> list[UserAccessToken]:
36
60
  async with app.state.sm.db.session_factory() as session:
37
61
  return list((await session.execute(select(UserAccessToken))).scalars())
@@ -129,15 +153,14 @@ class TestAnExpiredRowStopsAuthenticating:
129
153
  async def test_the_cookie_strategy_path_refuses_it(self, users_app, anon_client):
130
154
  """`fastapi_users.current_user`, via ExpiringDatabaseStrategy.
131
155
 
132
- ``/api/users/me`` is the probe because it depends on ``current_user``,
133
- which is what reads the ``sm_auth`` row. The row stays well inside the
134
- thirty-day read ceiling — only the deadline it was issued for has passed.
156
+ The row stays well inside the thirty-day read ceiling — only the
157
+ deadline it was issued for has passed.
135
158
  """
136
159
  assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
137
- assert (await anon_client.get(_ME)).status_code == 200
160
+ assert await _cookie_strategy_user(users_app, anon_client) is not None
138
161
 
139
162
  await _expire_all(users_app)
140
- assert (await anon_client.get(_ME)).status_code == 401
163
+ assert await _cookie_strategy_user(users_app, anon_client) is None
141
164
 
142
165
  @pytest.mark.anyio
143
166
  async def test_the_provider_path_refuses_it(self, users_app, anon_client):
@@ -77,6 +77,30 @@ async def _age_access_tokens(app, *, days: int) -> None:
77
77
  await session.commit()
78
78
 
79
79
 
80
+ async def _cookie_strategy_user(app, client):
81
+ """Resolve the ``sm_auth`` cookie through the cookie strategy itself.
82
+
83
+ ``/api/users/me`` now resolves the request principal (session or bearer,
84
+ #658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row
85
+ the way ``fastapi_users.current_user`` does instead.
86
+ """
87
+ from users.backend import build_strategy
88
+ from users.db_adapter import PruningAccessTokenDatabase
89
+ from users.models import UserAccessToken
90
+
91
+ async with app.state.sm.db.session_factory() as session:
92
+
93
+ class _Manager:
94
+ def parse_id(self, value):
95
+ return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
96
+
97
+ async def get(self, user_id):
98
+ return await session.get(User, user_id)
99
+
100
+ strategy = build_strategy(PruningAccessTokenDatabase(session, UserAccessToken), 900)
101
+ return await strategy.read_token(client.cookies.get("sm_auth"), _Manager())
102
+
103
+
80
104
  class TestRemembered:
81
105
  @pytest.mark.anyio
82
106
  async def test_the_auth_cookie_lasts_thirty_days(self, anon_client, users_db):
@@ -104,14 +128,13 @@ class TestRemembered:
104
128
  know which sign-ins asked to be remembered — a row minted to last
105
129
  thirty days therefore has to be accepted for thirty, or the cookie
106
130
  outlives the credential it carries and the checkbox starts lying on
107
- day fourteen. ``/api/users/me`` is the probe because it depends on
108
- ``current_user``, which is the code path that reads the row.
131
+ day fourteen.
109
132
  """
110
133
  await _make_user(users_db, "remember-token@example.com")
111
134
  await _login(anon_client, "remember-token@example.com", remember=True)
112
135
 
113
136
  await _age_access_tokens(users_app, days=29)
114
- assert (await anon_client.get("/api/users/me")).status_code == 200
137
+ assert await _cookie_strategy_user(users_app, anon_client) is not None
115
138
 
116
139
  @pytest.mark.anyio
117
140
  async def test_a_token_past_the_window_stops_being_read(self, anon_client, users_db, users_app):
@@ -121,7 +144,7 @@ class TestRemembered:
121
144
  await _login(anon_client, "stale-token@example.com", remember=True)
122
145
 
123
146
  await _age_access_tokens(users_app, days=31)
124
- assert (await anon_client.get("/api/users/me")).status_code == 401
147
+ assert await _cookie_strategy_user(users_app, anon_client) is None
125
148
 
126
149
 
127
150
  class TestTheWindowSurvivesLaterRequests:
@@ -0,0 +1,46 @@
1
+ """Self-profile routes accept local bearer tokens as well as browser cookies."""
2
+
3
+ import pytest
4
+ from _users_app_builders import user_row
5
+
6
+
7
+ @pytest.mark.parametrize("credential", ["bearer", "cookie"])
8
+ async def test_users_me_reads_and_updates_profile(anon_client, alice, users_app, credential):
9
+ if credential == "bearer":
10
+ login = await anon_client.post(
11
+ "/api/users/auth/token",
12
+ json={"email": alice.email, "password": "UserPass1!"},
13
+ )
14
+ assert login.status_code == 200
15
+ anon_client.cookies.clear()
16
+ headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
17
+ else:
18
+ login = await anon_client.post(
19
+ "/api/users/auth/login",
20
+ data={"username": alice.email, "password": "UserPass1!"},
21
+ )
22
+ assert login.status_code == 204
23
+ headers = {}
24
+
25
+ profile = await anon_client.get("/api/users/me", headers=headers)
26
+ assert profile.status_code == 200
27
+ assert profile.json()["id"] == str(alice.id)
28
+ assert profile.json()["email"] == alice.email
29
+ assert "hashed_password" not in profile.json()
30
+
31
+ updated = await anon_client.patch(
32
+ "/api/users/me", headers=headers, json={"full_name": "Updated Profile"}
33
+ )
34
+ assert updated.status_code == 200
35
+ assert updated.json()["id"] == str(alice.id)
36
+ assert updated.json()["full_name"] == "Updated Profile"
37
+ assert (await user_row(users_app, alice.email)).full_name == "Updated Profile"
38
+
39
+
40
+ @pytest.mark.parametrize("method", ["GET", "PATCH"])
41
+ @pytest.mark.parametrize("headers", [{}, {"Authorization": "Bearer invalid-token"}])
42
+ async def test_users_me_requires_valid_credentials(anon_client, method, headers):
43
+ response = await anon_client.request(
44
+ method, "/api/users/me", headers=headers, json={"full_name": "Unauthorized"}
45
+ )
46
+ assert response.status_code == 401
@@ -0,0 +1,111 @@
1
+ """Exercise callback error responses without a database or provider network."""
2
+
3
+ from types import SimpleNamespace
4
+ from unittest.mock import AsyncMock
5
+
6
+ import httpx
7
+ import pytest
8
+ from fastapi import APIRouter, FastAPI
9
+ from fastapi_users.exceptions import UserNotExists
10
+ from httpx_oauth.exceptions import GetProfileError
11
+ from starlette.middleware.sessions import SessionMiddleware
12
+ from users.deps import auth_backend, get_user_manager
13
+ from users.oauth.api import register_oauth_routes
14
+ from users.oauth.providers import OAuthProvider
15
+
16
+
17
+ @pytest.mark.anyio
18
+ @pytest.mark.parametrize(
19
+ ("failure", "detail", "public_code", "status"),
20
+ [
21
+ ("unverified", "OAUTH_ACCOUNT_NOT_VERIFIED", "account_not_verified", 403),
22
+ ("signup", "OAUTH_SIGNUP_DISABLED", "signup_disabled", 403),
23
+ ("provider", "OAUTH_PROVIDER_ERROR", "signin_failed", 400),
24
+ ("profile", "OAUTH_PROVIDER_ERROR", "signin_failed", 400),
25
+ ("token", "OAUTH_PROVIDER_ERROR", "signin_failed", 400),
26
+ ("state", "OAUTH_INVALID_STATE", "signin_failed", 400),
27
+ ("missing_code", "OAUTH_MISSING_CODE", "signin_failed", 400),
28
+ ],
29
+ )
30
+ @pytest.mark.parametrize("accept", ["text/html", "application/json", "*/*", "xhr"])
31
+ async def test_oauth_callback_error_response(failure, detail, public_code, status, accept):
32
+ app = FastAPI()
33
+ app.add_middleware(SessionMiddleware, secret_key="test-secret")
34
+ provider_client = AsyncMock()
35
+ provider_client.get_authorization_url.side_effect = lambda url, state: (
36
+ f"https://idp/?state={state}"
37
+ )
38
+ provider_client.get_access_token.return_value = {"access_token": "test-token"}
39
+ provider_client.get_profile.return_value = {"sub": "test-id", "email": "test@example.com"}
40
+ if failure == "token":
41
+ provider_client.get_access_token.side_effect = httpx.ConnectError("private details")
42
+ if failure == "profile":
43
+ provider_client.get_profile.side_effect = GetProfileError("private provider details")
44
+ app.state.users = SimpleNamespace(
45
+ oauth_clients={"oidc": OAuthProvider("oidc", "OIDC", provider_client)},
46
+ settings=SimpleNamespace(allow_signup=False),
47
+ )
48
+ manager = AsyncMock()
49
+ manager.get_by_oauth_account.side_effect = UserNotExists()
50
+ if failure == "unverified":
51
+ manager.get_by_email.return_value = SimpleNamespace(is_verified=False, invited_at=None)
52
+ else:
53
+ manager.get_by_email.side_effect = UserNotExists()
54
+
55
+ async def manager_dep():
56
+ return manager
57
+
58
+ async def strategy_dep():
59
+ return None
60
+
61
+ app.dependency_overrides[get_user_manager] = manager_dep
62
+ app.dependency_overrides[auth_backend.get_strategy] = strategy_dep
63
+ router = APIRouter(prefix="/api/users")
64
+ register_oauth_routes(router)
65
+ app.include_router(router)
66
+ async with httpx.AsyncClient(
67
+ transport=httpx.ASGITransport(app=app), base_url="http://test"
68
+ ) as client:
69
+ login = await client.get("/api/users/auth/oidc/login")
70
+ state = httpx.URL(login.headers["location"]).params["state"]
71
+ params = {"state": "bad" if failure == "state" else state}
72
+ if failure == "provider":
73
+ params["error"] = "<script>untrusted provider text</script>"
74
+ elif failure != "missing_code":
75
+ params["code"] = "test-code"
76
+ headers = {"accept": accept}
77
+ if accept == "xhr":
78
+ headers = {"accept": "text/html", "x-requested-with": "XMLHttpRequest"}
79
+ response = await client.get("/api/users/auth/oidc/callback", params=params, headers=headers)
80
+ if accept == "text/html":
81
+ assert response.status_code == 303
82
+ assert response.headers["location"] == f"/users/login?oauth_error={public_code}"
83
+ else:
84
+ assert response.status_code == status
85
+ assert response.json() == {"detail": detail}
86
+
87
+
88
+ @pytest.mark.anyio
89
+ @pytest.mark.parametrize(
90
+ "code", ["account_not_verified", "signup_disabled", "signin_failed", "<script>"]
91
+ )
92
+ async def test_login_view_filters_oauth_error_codes(code):
93
+ from starlette.requests import Request
94
+ from users.auth_local.views import login_page
95
+ from users.settings import UsersSettings
96
+
97
+ app = FastAPI()
98
+ app.state.users = SimpleNamespace(settings=UsersSettings(), oauth_providers=[])
99
+ app.state.sm = SimpleNamespace(settings=SimpleNamespace(is_development=False), modules=[])
100
+ request = Request(
101
+ {
102
+ "type": "http",
103
+ "app": app,
104
+ "session": {},
105
+ "query_string": str(httpx.QueryParams({"oauth_error": code})).encode(),
106
+ }
107
+ )
108
+ inertia = SimpleNamespace(render=AsyncMock())
109
+ await login_page(request, inertia)
110
+ props = inertia.render.call_args.args[1]
111
+ assert props["oauth_error"] == (None if code == "<script>" else code)
@@ -0,0 +1,51 @@
1
+ """Browser OAuth refusals return to login; API clients keep JSON (#654)."""
2
+
3
+ from urllib.parse import parse_qs, urlparse
4
+
5
+ import pytest
6
+ from _users_oauth_support import install_fake_provider, oauth_callback, seed_password_user
7
+
8
+
9
+ @pytest.mark.anyio
10
+ async def test_browser_unverified_account_returns_to_login(users_app, anon_client):
11
+ await seed_password_user(users_app, "refused@example.com", is_verified=False)
12
+ install_fake_provider(users_app, "refused", "refused@example.com")
13
+ anon_client.headers["accept"] = "text/html"
14
+
15
+ response = await oauth_callback(anon_client)
16
+
17
+ assert response.status_code == 303
18
+ assert response.headers["location"] == "/users/login?oauth_error=account_not_verified"
19
+ assert users_app.state.users.settings.cookie_name not in response.cookies
20
+
21
+
22
+ @pytest.mark.anyio
23
+ @pytest.mark.parametrize("browser", [True, False])
24
+ async def test_provider_refusal_is_sanitized(users_app, anon_client, browser):
25
+ install_fake_provider(users_app, "refused", "refused@example.com")
26
+ login = await anon_client.get("/api/users/auth/oidc/login", follow_redirects=False)
27
+ state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
28
+ response = await anon_client.get(
29
+ "/api/users/auth/oidc/callback",
30
+ params={"state": state, "error": "<script>provider text</script>"},
31
+ headers={"accept": "text/html" if browser else "application/json"},
32
+ follow_redirects=False,
33
+ )
34
+ if browser:
35
+ assert response.status_code == 303
36
+ assert response.headers["location"] == "/users/login?oauth_error=signin_failed"
37
+ else:
38
+ assert response.status_code == 400
39
+ assert response.json()["detail"] == "OAUTH_PROVIDER_ERROR"
40
+
41
+
42
+ @pytest.mark.anyio
43
+ @pytest.mark.parametrize(
44
+ "code", ["account_not_verified", "signup_disabled", "signin_failed", "<script>"]
45
+ )
46
+ async def test_login_only_exposes_known_oauth_errors(anon_client, code):
47
+ response = await anon_client.get(
48
+ "/users/login", params={"oauth_error": code}, headers={"X-Inertia": "true"}
49
+ )
50
+ assert response.status_code == 200
51
+ assert response.json()["props"]["oauth_error"] == (None if code == "<script>" else code)
@@ -0,0 +1,178 @@
1
+ """Absolute refresh-family age bounds retained rotation history (#656)."""
2
+
3
+ import asyncio
4
+ import uuid
5
+ from datetime import UTC, datetime, timedelta
6
+ from types import SimpleNamespace
7
+
8
+ import pytest
9
+ import sqlalchemy as sa
10
+ from pydantic import ValidationError
11
+ from sqlalchemy.orm import Session
12
+ from starlette.responses import JSONResponse
13
+ from users.auth_local.token_api import RefreshRequest, TokenResponse, token_refresh
14
+ from users.db_adapter import get_access_token_db
15
+ from users.models import Base, RefreshToken, User, UserAccessToken
16
+ from users.settings import UsersSettings
17
+ from users.token_storage import hash_token, prune_tokens
18
+
19
+
20
+ @pytest.fixture
21
+ def family_db():
22
+ engine = sa.create_engine("sqlite://")
23
+ Base.metadata.create_all(engine)
24
+ try:
25
+ with Session(engine) as session:
26
+ owner = uuid.uuid4()
27
+ session.add(User(id=owner, email="family@example.com"))
28
+ session.flush()
29
+ yield session, owner
30
+ finally:
31
+ engine.dispose()
32
+
33
+
34
+ class _AsyncSession:
35
+ def __init__(self, session):
36
+ self.session = session
37
+
38
+ def add(self, row):
39
+ self.session.add(row)
40
+
41
+ async def flush(self):
42
+ self.session.flush()
43
+
44
+ async def commit(self):
45
+ self.session.commit()
46
+
47
+ async def refresh(self, row):
48
+ self.session.refresh(row)
49
+
50
+ async def scalar(self, statement):
51
+ return self.session.scalar(statement)
52
+
53
+ async def execute(self, statement):
54
+ return self.session.execute(statement)
55
+
56
+
57
+ def _family(session, owner, now, age_days, *, children=1):
58
+ family = uuid.uuid4()
59
+ session.add(
60
+ RefreshToken(
61
+ token=str(uuid.uuid4()),
62
+ user_id=owner,
63
+ family_id=family,
64
+ created_at=now - timedelta(days=age_days),
65
+ expires_at=now - timedelta(days=1),
66
+ revoked_at=now - timedelta(days=1),
67
+ )
68
+ )
69
+ secrets = [str(uuid.uuid4()) for _ in range(children)]
70
+ for secret in secrets:
71
+ session.add(
72
+ RefreshToken(
73
+ token=hash_token(secret),
74
+ user_id=owner,
75
+ family_id=family,
76
+ created_at=now,
77
+ expires_at=now + timedelta(days=30),
78
+ )
79
+ )
80
+ session.flush()
81
+ return secrets
82
+
83
+
84
+ @pytest.mark.parametrize("age_days", [7, 8, 91])
85
+ @pytest.mark.parametrize("expired", [False, True])
86
+ def test_users_refresh_refuses_old_family_and_revokes_all_live_children(
87
+ family_db, expired, age_days
88
+ ):
89
+ session, owner = family_db
90
+ now = datetime.now(UTC)
91
+ secrets = _family(session, owner, now, age_days, children=2)
92
+ if expired:
93
+ session.get(RefreshToken, hash_token(secrets[0])).expires_at = now - timedelta(seconds=1)
94
+ session.flush()
95
+ settings = (
96
+ UsersSettings() if age_days == 91 else UsersSettings(refresh_token_family_max_age_days=7)
97
+ )
98
+ request = _request(settings)
99
+ response = asyncio.run(
100
+ token_refresh(RefreshRequest(refresh_token=secrets[0]), request, _AsyncSession(session))
101
+ )
102
+ assert isinstance(response, JSONResponse)
103
+ assert response.status_code == 401
104
+ session.expire_all()
105
+ assert all(row.revoked_at is not None for row in session.scalars(sa.select(RefreshToken)))
106
+ assert session.scalar(sa.select(sa.func.count()).select_from(UserAccessToken)) == 0
107
+
108
+
109
+ def test_users_prune_revokes_old_family_before_bounded_deletion(family_db):
110
+ session, owner = family_db
111
+ now = datetime.now(UTC)
112
+ _family(session, owner, now, 8, children=101)
113
+ recent = _family(session, owner, now, 6)
114
+ other = uuid.uuid4()
115
+ session.add(User(id=other, email="other@example.com"))
116
+ session.flush()
117
+ other_old = _family(session, other, now, 8)
118
+
119
+ for _ in range(2):
120
+ asyncio.run(prune_tokens(_AsyncSession(session), owner, now, family_max_age_days=7))
121
+ session.expire_all()
122
+ # Even if the oldest ancestor was in the first batch, no child becomes live again.
123
+ remaining = list(
124
+ session.scalars(
125
+ sa.select(RefreshToken).where(
126
+ RefreshToken.user_id == owner, RefreshToken.token != hash_token(recent[0])
127
+ )
128
+ )
129
+ )
130
+ assert all(row.revoked_at is not None for row in remaining)
131
+ assert session.scalar(sa.select(sa.func.count()).select_from(RefreshToken)) == 4
132
+ assert session.get(RefreshToken, hash_token(recent[0])).revoked_at is None
133
+ assert session.get(RefreshToken, hash_token(other_old[0])).revoked_at is None
134
+
135
+
136
+ def test_users_family_max_age_rejects_zero():
137
+ with pytest.raises(ValidationError):
138
+ UsersSettings(refresh_token_family_max_age_days=0)
139
+
140
+
141
+ def _request(settings):
142
+ return SimpleNamespace(
143
+ app=SimpleNamespace(
144
+ state=SimpleNamespace(users=SimpleNamespace(settings=settings, demo_user_ids=set()))
145
+ )
146
+ )
147
+
148
+
149
+ def test_users_recent_family_rotates_and_keeps_expired_ancestor_reuse_evidence(family_db):
150
+ session, owner = family_db
151
+ now = datetime.now(UTC)
152
+ secrets = _family(session, owner, now, 6)
153
+ ancestor = session.scalar(sa.select(RefreshToken).where(RefreshToken.revoked_at.is_not(None)))
154
+ ancestor_secret = str(uuid.uuid4())
155
+ ancestor.token = hash_token(ancestor_secret)
156
+ session.flush()
157
+ request = _request(UsersSettings(refresh_token_family_max_age_days=7))
158
+ db = _AsyncSession(session)
159
+ rotated = asyncio.run(token_refresh(RefreshRequest(refresh_token=secrets[0]), request, db))
160
+ assert isinstance(rotated, TokenResponse)
161
+ assert session.get(RefreshToken, hash_token(ancestor_secret)) is not None
162
+ for secret in (ancestor_secret, rotated.refresh_token):
163
+ refused = asyncio.run(token_refresh(RefreshRequest(refresh_token=secret), request, db))
164
+ assert refused.status_code == 401
165
+
166
+
167
+ def test_users_cookie_cleanup_uses_configured_family_max_age(family_db):
168
+ session, owner = family_db
169
+ now = datetime.now(UTC)
170
+ _family(session, owner, now, 8)
171
+ request = _request(UsersSettings(refresh_token_family_max_age_days=7))
172
+
173
+ async def login():
174
+ async for database in get_access_token_db(request, _AsyncSession(session)):
175
+ await database.create({"token": "cookie", "user_id": owner})
176
+
177
+ asyncio.run(login())
178
+ assert session.scalar(sa.select(sa.func.count()).select_from(RefreshToken)) == 0
@@ -56,7 +56,7 @@ async def test_users_concurrent_refresh_has_one_winner(monkeypatch, tmp_path):
56
56
  ):
57
57
  frozen = result.freeze()
58
58
  row = frozen().scalar_one_or_none()
59
- if row is not None and row.revoked_at is None:
59
+ if isinstance(row, RefreshToken) and row.revoked_at is None:
60
60
  # Force both stale reads before either request can burn the token.
61
61
  await readers.wait()
62
62
  return frozen()
@@ -0,0 +1,40 @@
1
+ """A password policy rejection must leave a fresh reset token usable (#667)."""
2
+
3
+ from urllib.parse import parse_qs, urlsplit
4
+
5
+ import pytest
6
+ from _users_app_builders import _make_standard_user
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ ("password", "reason"),
11
+ [
12
+ ("short", "Password must be at least 8 characters, not counting spaces"),
13
+ ("12345678", "Password cannot be all numbers"),
14
+ ],
15
+ )
16
+ async def test_users_reset_rejected_password_allows_same_token_retry(
17
+ admin_client, anon_client, users_app, password, reason
18
+ ):
19
+ user = await _make_standard_user(users_app)
20
+ link = await admin_client.post(f"/api/users/admin/{user.id}/reset-password-link")
21
+ assert link.status_code == 200, link.text
22
+ token = parse_qs(urlsplit(link.json()["link"]).query)["token"][0]
23
+
24
+ rejected = await anon_client.post(
25
+ "/api/users/auth/reset-password", json={"token": token, "password": password}
26
+ )
27
+ assert rejected.status_code == 400, rejected.text
28
+ assert rejected.json()["detail"] == {
29
+ "code": "RESET_PASSWORD_INVALID_PASSWORD",
30
+ "reason": reason,
31
+ }
32
+
33
+ accepted = await anon_client.post(
34
+ "/api/users/auth/reset-password", json={"token": token, "password": "CompliantPass1!"}
35
+ )
36
+ assert accepted.status_code == 200, accepted.text
37
+ login = await anon_client.post(
38
+ "/api/users/auth/login", data={"username": user.email, "password": "CompliantPass1!"}
39
+ )
40
+ assert login.status_code == 204, login.text
@@ -105,7 +105,9 @@ def test_users_token_pair_persists_only_digests_with_sync_sqlite():
105
105
  _SyncSession(session),
106
106
  user_id,
107
107
  SimpleNamespace(
108
- bearer_token_lifetime_seconds=900, refresh_token_lifetime_seconds=3600
108
+ bearer_token_lifetime_seconds=900,
109
+ refresh_token_lifetime_seconds=3600,
110
+ refresh_token_family_max_age_days=90,
109
111
  ),
110
112
  )
111
113
  )
@@ -42,6 +42,22 @@ afterEach(() => {
42
42
  });
43
43
 
44
44
  describe('users Login', () => {
45
+ it.each([
46
+ ['account_not_verified', keys.users.login.oauth_account_not_verified],
47
+ ['signup_disabled', keys.users.login.oauth_signup_disabled],
48
+ ['signin_failed', keys.users.login.oauth_signin_failed],
49
+ ])('shows the translated OAuth message for %s', (code, key) => {
50
+ configureI18n({ locale: 'en', messages: { [key]: 'Sign-in was refused.' } });
51
+ render(<Login {...props} oauth_error={code} />);
52
+ expect(screen.getByRole('alert')).toHaveTextContent('Sign-in was refused.');
53
+ configureI18n({ locale: 'en', messages: {} });
54
+ });
55
+
56
+ it('ignores unknown OAuth codes instead of displaying query text', () => {
57
+ render(<Login {...props} oauth_error="<script>untrusted text</script>" />);
58
+ expect(screen.queryByRole('alert')).toBeNull();
59
+ });
60
+
45
61
  it('posts the form-encoded credentials and follows the server redirect on 204', async () => {
46
62
  const mock = stubFetch(() => new Response(null, { status: 204 }));
47
63
  render(<Login {...props} />);