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.
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/PKG-INFO +7 -7
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/package.json +1 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/pyproject.toml +7 -7
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bearer_token_expiry.py +28 -5
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_remember_me.py +27 -4
- simple_module_users-0.0.41/tests/test_users_me_credentials.py +46 -0
- simple_module_users-0.0.41/tests/test_users_oauth_error_responses.py +111 -0
- simple_module_users-0.0.41/tests/test_users_oauth_login_errors.py +51 -0
- simple_module_users-0.0.41/tests/test_users_refresh_family_max_age.py +178 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_refresh_reuse.py +1 -1
- simple_module_users-0.0.41/tests/test_users_reset_password_retry.py +40 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_token_pruning.py +3 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_Login.test.tsx +16 -0
- simple_module_users-0.0.41/tests-js/users_ResetPassword.test.tsx +114 -0
- simple_module_users-0.0.41/tests-js/users_admin_error_details.test.tsx +112 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/useUserRowActions.ts +10 -4
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/api.py +3 -3
- simple_module_users-0.0.41/users/auth_local/resetPasswordError.ts +13 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/token_api.py +22 -5
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/views.py +3 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/constants.py +2 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/db_adapter.py +24 -3
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/deps.py +17 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/locales/de.json +14 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/locales/en.json +14 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/oauth/api.py +19 -24
- simple_module_users-0.0.41/users/oauth/errors.py +27 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Login.tsx +11 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/ResetPassword.tsx +3 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/DangerZone.tsx +3 -3
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/useEditUserForm.ts +16 -8
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/useUserActions.ts +6 -4
- simple_module_users-0.0.41/users/pages/failureMessage.ts +41 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/settings.py +4 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/token_storage.py +26 -3
- simple_module_users-0.0.40/users/pages/failureMessage.ts +0 -16
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/.gitignore +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/LICENSE +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/README.md +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/_users_oauth_support.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/conftest.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_account.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_credentials.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_guard.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/demo/test_users_demo_login.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_invite_flow.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_invite_reuse.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/invites/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_admin_unlock.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_checkpoint.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_columns.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_enumeration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_service.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/lockout/test_users_login_lockout.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_admin_pagination_bounds.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_auth_screens_props.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_legacy_cached_tenant.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_login_redirect_root_mount.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_revocation_broadcast.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_revocation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_atomic.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_effective_permissions.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_privilege_guards.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_role_grant_rules.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_admin_session_revocation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_bearer_scheme.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_default_role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_deletion_events.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_directory.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_domain_errors.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_login_session_rotation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_mailer_core_sender.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_manager.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_email_claim.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_provider_identity.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_signup_gate.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_oauth_unverified_link.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_password_change_keeps_api.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_register_role_durable.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_register_single_connection.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_require_verification.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_reset_link_origin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_seed_role_migration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_setup_lock.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_setup_wizard.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_startup_role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_token_hash_migration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_token_storage.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_users_update_schema.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_views.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/AccountCardLocked.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/LoginForm.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/registerErrors.test.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_Register.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_VerifyEmail.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_actions.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_fixtures.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tests-js/users_profile_cards.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/tsconfig.json +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/api.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/events.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/links.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/queries.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/recent_activity.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/safety.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/service.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/admin/views.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/audit.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/LoginForm.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/lockout/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/lockout/attempt.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/registerErrors.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/self_account.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/backend.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/cli.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/roles.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/demo/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/demo/api.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/demo/guard.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/directory.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/errors.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/exceptions.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/bulk.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/preview.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/invites/resend.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templated.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/manager.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/middleware.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/_base.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/user.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/module.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/AddPeople.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/MailerBanner.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/components/useAddPeople.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/api.generated.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/api.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/pages/signIn.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/provider.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/py.typed +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/registration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/session_revocation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/setup.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/setup_action.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/shared_props.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/startup.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.41}/users/state.py +0 -0
- {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.
|
|
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.
|
|
27
|
-
Requires-Dist: simple-module-core==0.0.
|
|
28
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
30
|
-
Requires-Dist: simple-module-mail==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
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
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
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.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
28
|
-
"simple_module_auth==0.0.
|
|
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.
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
160
|
+
assert await _cookie_strategy_user(users_app, anon_client) is not None
|
|
138
161
|
|
|
139
162
|
await _expire_all(users_app)
|
|
140
|
-
assert
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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} />);
|