simple-module-users 0.0.35__tar.gz → 0.0.37__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/.gitignore +1 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/PKG-INFO +6 -6
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/pyproject.toml +6 -6
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/_middleware_support.py +0 -2
- simple_module_users-0.0.37/tests/_users_oauth_support.py +84 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_auth_screens_props.py +7 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_external_users.py +6 -45
- simple_module_users-0.0.37/tests/test_legacy_cached_tenant.py +54 -0
- simple_module_users-0.0.37/tests/test_login_redirect_root_mount.py +39 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_negative_authz.py +4 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_token_api.py +22 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_user_manager.py +42 -4
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_user_model.py +0 -1
- simple_module_users-0.0.37/tests/test_users_bearer_scheme.py +54 -0
- simple_module_users-0.0.37/tests/test_users_default_role.py +82 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_middleware.py +0 -1
- simple_module_users-0.0.37/tests/test_users_oauth_signup_gate.py +63 -0
- simple_module_users-0.0.37/tests/test_users_oauth_unverified_link.py +85 -0
- simple_module_users-0.0.37/tests/test_users_register_role_durable.py +54 -0
- simple_module_users-0.0.37/tests/test_users_require_verification.py +104 -0
- simple_module_users-0.0.37/tests/test_users_seed_role_migration.py +77 -0
- simple_module_users-0.0.37/tests/test_users_setup_lock.py +63 -0
- simple_module_users-0.0.37/tests/test_users_setup_wizard.py +187 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_shared_props.py +15 -0
- simple_module_users-0.0.37/tests-js/LoginForm.test.tsx +37 -0
- simple_module_users-0.0.37/tests-js/Register.test.tsx +77 -0
- simple_module_users-0.0.37/tests-js/registerErrors.test.ts +72 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/queries.py +9 -9
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/api.py +5 -3
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/LoginForm.tsx +7 -3
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/PasswordFields.tsx +5 -1
- simple_module_users-0.0.37/users/auth_local/registerErrors.ts +79 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/token_api.py +13 -3
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/views.py +19 -3
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/bootstrap.py +22 -12
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/contracts/schemas.py +0 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/deps.py +2 -2
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/locales/en.json +15 -5
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/manager.py +30 -4
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/role.py +16 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/user.py +5 -5
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/user_role.py +3 -2
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/oauth/api.py +53 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/AcceptInvite.tsx +3 -3
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Login.tsx +7 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Register.tsx +86 -34
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/ResetPassword.tsx +3 -2
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/AddPeople.tsx +103 -96
- simple_module_users-0.0.37/users/pages/Users/Edit.tsx +129 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/Index.tsx +69 -66
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/provider.py +14 -4
- simple_module_users-0.0.37/users/registration.py +41 -0
- simple_module_users-0.0.37/users/setup.py +101 -0
- simple_module_users-0.0.37/users/setup_action.py +129 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/shared_props.py +8 -2
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/startup.py +2 -6
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/token_strategy.py +4 -1
- simple_module_users-0.0.35/users/pages/Users/Edit.tsx +0 -126
- simple_module_users-0.0.35/users/setup.py +0 -51
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/LICENSE +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/README.md +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/package.json +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/conftest.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_bearer_token_expiry.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_demo_account.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_demo_guard.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_demo_login.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_remember_me.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_revocation_broadcast.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_session_revocation.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_views.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/tsconfig.json +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/api.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/bulk_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/useUserRowActions.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/recent_activity.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/resend_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/service.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/admin/views.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/audit.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/demo_api.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/invite_preview.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/self_account.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/backend.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/cli.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/constants.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/demo.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/demo_guard.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/exceptions.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/health.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/middleware.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/_base.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/module.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/useEditUserForm.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/components/useUserActions.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/py.typed +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/session_revocation.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/settings.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.37}/users/state.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.37
|
|
4
4
|
Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Requires-Dist: aiosmtplib>=3.0
|
|
25
25
|
Requires-Dist: cachetools>=5.3
|
|
26
26
|
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
27
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
27
|
+
Requires-Dist: simple-module-auth==0.0.37
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.37
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.37
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.37
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.37
|
|
32
32
|
Requires-Dist: typer>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.37"
|
|
4
4
|
description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,11 +21,11 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
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.37",
|
|
25
|
+
"simple_module_db==0.0.37",
|
|
26
|
+
"simple_module_hosting==0.0.37",
|
|
27
|
+
"simple_module_settings==0.0.37",
|
|
28
|
+
"simple_module_auth==0.0.37",
|
|
29
29
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
30
30
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
31
31
|
# Bumping the major version requires re-checking those field names.
|
|
@@ -49,7 +49,6 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
|
|
|
49
49
|
"email": user.email,
|
|
50
50
|
"name": user.name,
|
|
51
51
|
"roles": user.roles,
|
|
52
|
-
"tenant_id": user.tenant_id,
|
|
53
52
|
}
|
|
54
53
|
if user is not None
|
|
55
54
|
else None
|
|
@@ -106,7 +105,6 @@ async def mw_active_user(db_session, _mw_seed_roles):
|
|
|
106
105
|
is_superuser=False,
|
|
107
106
|
is_verified=True,
|
|
108
107
|
full_name="Middleware Tester",
|
|
109
|
-
tenant_id="acme",
|
|
110
108
|
)
|
|
111
109
|
link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID)
|
|
112
110
|
db_session.add_all([user, link])
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A fake OAuth provider (no network) to drive the ``/login`` + ``/callback`` flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastapi_users.password import PasswordHelper
|
|
10
|
+
from sqlalchemy import select
|
|
11
|
+
from users.models import User
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _FakeOAuthClient:
|
|
15
|
+
def __init__(self, account_id: str, account_email: str) -> None:
|
|
16
|
+
self._account_id = account_id
|
|
17
|
+
self._account_email = account_email
|
|
18
|
+
|
|
19
|
+
async def get_authorization_url(self, redirect_uri: str, state: str) -> str:
|
|
20
|
+
return f"https://idp.example/authorize?state={state}"
|
|
21
|
+
|
|
22
|
+
async def get_access_token(self, code: str, redirect_uri: str) -> dict:
|
|
23
|
+
return {"access_token": "fake-token", "expires_at": None, "refresh_token": None}
|
|
24
|
+
|
|
25
|
+
async def get_id_email(self, access_token: str) -> tuple[str, str]:
|
|
26
|
+
return self._account_id, self._account_email
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install_fake_provider(app, account_id: str, account_email: str) -> None:
|
|
30
|
+
from users.oauth import OAuthProvider
|
|
31
|
+
|
|
32
|
+
app.state.users.oauth_clients["microsoft"] = OAuthProvider(
|
|
33
|
+
"microsoft", "Microsoft", _FakeOAuthClient(account_id, account_email)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_users_settings(app, **update) -> None:
|
|
38
|
+
"""Change fields on the live users settings, as an admin edit would."""
|
|
39
|
+
settings = app.state.users.settings
|
|
40
|
+
app.state.users.settings = settings.model_copy(update=update)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_allow_signup(app, allowed: bool) -> None:
|
|
44
|
+
"""Flip ``allow_signup`` on the live settings, as an admin edit would."""
|
|
45
|
+
set_users_settings(app, allow_signup=allowed)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def find_user(app, email: str) -> User | None:
|
|
49
|
+
async with app.state.sm.db.session_factory() as session:
|
|
50
|
+
stmt = select(User).where(User.email == email)
|
|
51
|
+
return (await session.execute(stmt)).scalar_one_or_none()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def seed_password_user(app, email: str, **fields) -> uuid.UUID:
|
|
55
|
+
"""Insert an active password account for ``email``; return its id."""
|
|
56
|
+
user_id = uuid.uuid4()
|
|
57
|
+
async with app.state.sm.db.session_factory() as session:
|
|
58
|
+
session.add(
|
|
59
|
+
User(
|
|
60
|
+
id=user_id,
|
|
61
|
+
email=email,
|
|
62
|
+
hashed_password=PasswordHelper().hash("SecurePass1!"),
|
|
63
|
+
is_active=True,
|
|
64
|
+
**fields,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
await session.commit()
|
|
68
|
+
return user_id
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def oauth_callback(client: httpx.AsyncClient) -> httpx.Response:
|
|
72
|
+
"""Drive /login then /callback; return the callback response."""
|
|
73
|
+
login = await client.get("/api/users/auth/microsoft/login", follow_redirects=False)
|
|
74
|
+
assert login.status_code == 302
|
|
75
|
+
state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
|
|
76
|
+
return await client.get(
|
|
77
|
+
f"/api/users/auth/microsoft/callback?code=abc&state={state}",
|
|
78
|
+
follow_redirects=False,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def run_oauth_login(client: httpx.AsyncClient) -> int:
|
|
83
|
+
"""Drive /login then /callback; return the callback status code."""
|
|
84
|
+
return (await oauth_callback(client)).status_code
|
|
@@ -257,3 +257,10 @@ class TestAcceptInviteFullName:
|
|
|
257
257
|
await session.execute(select(User).where(User.email == "joins@example.com"))
|
|
258
258
|
).scalar_one()
|
|
259
259
|
assert stored.full_name == "Rob Meyer"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TestLoginAsideProps:
|
|
263
|
+
@pytest.mark.anyio
|
|
264
|
+
async def test_keycloak_flag_is_false_without_the_module(self, anon_client):
|
|
265
|
+
props = _props(await anon_client.get("/users/login", headers=_INERTIA_HEADERS))
|
|
266
|
+
assert props["keycloak_installed"] is False
|
|
@@ -11,9 +11,9 @@ Covers the feature added on top of the OAuth/OIDC login flow:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import uuid
|
|
14
|
-
from urllib.parse import parse_qs, urlparse
|
|
15
14
|
|
|
16
15
|
import pytest
|
|
16
|
+
from _users_oauth_support import install_fake_provider, run_oauth_login, set_allow_signup
|
|
17
17
|
from fastapi_users.password import PasswordHelper
|
|
18
18
|
from sqlalchemy import select
|
|
19
19
|
from users.models import Role, User
|
|
@@ -21,46 +21,6 @@ from users.models import Role, User
|
|
|
21
21
|
_pw = PasswordHelper()
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
# ---------------------------------------------------------------------------
|
|
25
|
-
# Fake OAuth provider (no network) to drive the /login + /callback flow
|
|
26
|
-
# ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class _FakeOAuthClient:
|
|
30
|
-
def __init__(self, account_id: str, account_email: str) -> None:
|
|
31
|
-
self._account_id = account_id
|
|
32
|
-
self._account_email = account_email
|
|
33
|
-
|
|
34
|
-
async def get_authorization_url(self, redirect_uri: str, state: str) -> str:
|
|
35
|
-
return f"https://idp.example/authorize?state={state}"
|
|
36
|
-
|
|
37
|
-
async def get_access_token(self, code: str, redirect_uri: str) -> dict:
|
|
38
|
-
return {"access_token": "fake-token", "expires_at": None, "refresh_token": None}
|
|
39
|
-
|
|
40
|
-
async def get_id_email(self, access_token: str) -> tuple[str, str]:
|
|
41
|
-
return self._account_id, self._account_email
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _install_fake_provider(app, account_id: str, account_email: str) -> None:
|
|
45
|
-
from users.oauth import OAuthProvider
|
|
46
|
-
|
|
47
|
-
app.state.users.oauth_clients["microsoft"] = OAuthProvider(
|
|
48
|
-
"microsoft", "Microsoft", _FakeOAuthClient(account_id, account_email)
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
async def _run_oauth_login(client) -> int:
|
|
53
|
-
"""Drive /login then /callback; return the callback status code."""
|
|
54
|
-
login = await client.get("/api/users/auth/microsoft/login", follow_redirects=False)
|
|
55
|
-
assert login.status_code == 302
|
|
56
|
-
state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
|
|
57
|
-
cb = await client.get(
|
|
58
|
-
f"/api/users/auth/microsoft/callback?code=abc&state={state}",
|
|
59
|
-
follow_redirects=False,
|
|
60
|
-
)
|
|
61
|
-
return cb.status_code
|
|
62
|
-
|
|
63
|
-
|
|
64
24
|
async def _get_user_by_email(db, email: str) -> User:
|
|
65
25
|
return (await db.execute(select(User).where(User.email == email))).scalar_one()
|
|
66
26
|
|
|
@@ -72,9 +32,10 @@ async def _get_user_by_email(db, email: str) -> User:
|
|
|
72
32
|
|
|
73
33
|
@pytest.mark.anyio
|
|
74
34
|
async def test_new_oauth_login_creates_external_user(users_app, anon_client, users_db):
|
|
75
|
-
|
|
35
|
+
set_allow_signup(users_app, True) # creating an account via OAuth is a signup (#543)
|
|
36
|
+
install_fake_provider(users_app, "ms-oid-1", "sso-new@example.com")
|
|
76
37
|
|
|
77
|
-
assert await
|
|
38
|
+
assert await run_oauth_login(anon_client) == 303
|
|
78
39
|
|
|
79
40
|
user = await _get_user_by_email(users_db, "sso-new@example.com")
|
|
80
41
|
assert user.is_external is True
|
|
@@ -96,8 +57,8 @@ async def test_oauth_login_links_existing_password_user_stays_local(
|
|
|
96
57
|
users_db.add(existing)
|
|
97
58
|
await users_db.commit()
|
|
98
59
|
|
|
99
|
-
|
|
100
|
-
assert await
|
|
60
|
+
install_fake_provider(users_app, "ms-oid-2", "local@example.com")
|
|
61
|
+
assert await run_oauth_login(anon_client) == 303
|
|
101
62
|
|
|
102
63
|
user = await _get_user_by_email(users_db, "local@example.com")
|
|
103
64
|
assert user.is_external is False
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""A pre-#381 session cookie's cached ``tenant_id`` is ignored.
|
|
2
|
+
|
|
3
|
+
``users_user.tenant_id`` is gone and the active tenant is resolved per request
|
|
4
|
+
from memberships, but cookies signed before the upgrade still carry the old
|
|
5
|
+
value inside ``user_ctx``. Honouring it would keep acting for a tenant the
|
|
6
|
+
user may have left.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from auth.contracts.schemas import UserContext
|
|
15
|
+
from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY
|
|
16
|
+
from sqlalchemy import select
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from users.models import User
|
|
19
|
+
from users.provider import UsersAuthProvider
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _admin_id(users_app) -> str:
|
|
23
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
24
|
+
stmt = select(User.id).where(User.email == "admin@example.com")
|
|
25
|
+
return str((await session.execute(stmt)).scalar_one())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.anyio
|
|
29
|
+
async def test_old_cookie_tenant_id_is_dropped_on_the_cached_path(users_app):
|
|
30
|
+
user_id = await _admin_id(users_app)
|
|
31
|
+
legacy_ctx = UserContext(
|
|
32
|
+
id=user_id, email="admin@example.com", name="Admin", roles=["admin"], tenant_id="acme"
|
|
33
|
+
).to_session_dict()
|
|
34
|
+
session = {
|
|
35
|
+
"user_id": user_id,
|
|
36
|
+
"user_ctx": legacy_ctx,
|
|
37
|
+
"session_version": 0,
|
|
38
|
+
SESSION_EXPIRES_AT_KEY: int(time.time()) + 3600,
|
|
39
|
+
}
|
|
40
|
+
scope = {
|
|
41
|
+
"type": "http",
|
|
42
|
+
"method": "GET",
|
|
43
|
+
"path": "/",
|
|
44
|
+
"headers": [],
|
|
45
|
+
"app": users_app,
|
|
46
|
+
"session": session,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ctx = await UsersAuthProvider().resolve_user(Request(scope))
|
|
50
|
+
|
|
51
|
+
assert ctx is not None
|
|
52
|
+
assert ctx.id == user_id
|
|
53
|
+
assert ctx.name == "Admin" # the cached context was used, not a DB reload
|
|
54
|
+
assert ctx.tenant_id is None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Login fallback with a module mounted at the root (#435)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _fallback_url(app, prefixes: dict[str, str]) -> str:
|
|
9
|
+
"""Run the login fallback against a Dashboard-less module set (#435)."""
|
|
10
|
+
from users.startup import apply_login_redirect_fallback
|
|
11
|
+
|
|
12
|
+
sm = app.state.sm
|
|
13
|
+
saved_mods, saved_prefixes = sm.modules, dict(sm.view_prefixes)
|
|
14
|
+
settings = app.state.users.settings
|
|
15
|
+
saved_url = settings.login_redirect_url
|
|
16
|
+
object.__setattr__(sm, "modules", tuple(m for m in saved_mods if m.meta.name != "Dashboard"))
|
|
17
|
+
sm.view_prefixes.clear()
|
|
18
|
+
sm.view_prefixes.update(prefixes)
|
|
19
|
+
settings.login_redirect_url = "/dashboard/"
|
|
20
|
+
try:
|
|
21
|
+
apply_login_redirect_fallback(app, settings, "Users", "/dashboard/")
|
|
22
|
+
return settings.login_redirect_url
|
|
23
|
+
finally:
|
|
24
|
+
object.__setattr__(sm, "modules", saved_mods)
|
|
25
|
+
sm.view_prefixes.clear()
|
|
26
|
+
sm.view_prefixes.update(saved_prefixes)
|
|
27
|
+
settings.login_redirect_url = saved_url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.anyio
|
|
31
|
+
async def test_login_redirect_fallback_prefers_root_mounted_module(users_app):
|
|
32
|
+
"""#435: a module that serves views at ``/`` (view_prefix == "") wins."""
|
|
33
|
+
assert _fallback_url(users_app, {"Users": "/users", "Home": "", "Other": "/o"}) == "/"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.anyio
|
|
37
|
+
async def test_login_redirect_fallback_skips_api_only_module(users_app):
|
|
38
|
+
"""An API-only module also has view_prefix == "" but is not in the map."""
|
|
39
|
+
assert _fallback_url(users_app, {"Users": "/users", "Other": "/o"}) == "/o/"
|
|
@@ -17,7 +17,10 @@ import uuid
|
|
|
17
17
|
|
|
18
18
|
import pytest
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# Fixed, not ``uuid4()``: the id lands in the parametrize ids, and pytest-xdist
|
|
21
|
+
# refuses to run when its workers collect differently-named tests. The request
|
|
22
|
+
# must be refused before any lookup, so which id it names does not matter.
|
|
23
|
+
_FAKE_ID = uuid.UUID("00000000-0000-4000-8000-000000000000")
|
|
21
24
|
_PROTECTED_ENDPOINTS: tuple[tuple[str, str, dict | None], ...] = (
|
|
22
25
|
# users — admin sub-router
|
|
23
26
|
("GET", "/api/users/admin", None),
|
|
@@ -71,6 +71,27 @@ class TestTokenLogin:
|
|
|
71
71
|
)
|
|
72
72
|
assert resp.status_code == 401
|
|
73
73
|
|
|
74
|
+
@pytest.mark.anyio
|
|
75
|
+
async def test_legacy_hash_is_upgraded_on_token_login(self, anon_client, users_db):
|
|
76
|
+
from pwdlib.hashers.bcrypt import BcryptHasher
|
|
77
|
+
|
|
78
|
+
legacy = BcryptHasher().hash("SecurePass1!")
|
|
79
|
+
assert legacy.startswith("$2")
|
|
80
|
+
user = await _seed_user(users_db, email="legacy@example.com")
|
|
81
|
+
user.hashed_password = legacy
|
|
82
|
+
await users_db.commit()
|
|
83
|
+
|
|
84
|
+
for _ in range(2): # second login proves the upgraded hash still verifies
|
|
85
|
+
resp = await anon_client.post(
|
|
86
|
+
"/api/users/auth/token",
|
|
87
|
+
json={"email": "legacy@example.com", "password": "SecurePass1!"},
|
|
88
|
+
)
|
|
89
|
+
assert resp.status_code == 200
|
|
90
|
+
|
|
91
|
+
await users_db.refresh(user)
|
|
92
|
+
assert user.hashed_password != legacy
|
|
93
|
+
assert user.hashed_password.startswith("$argon2")
|
|
94
|
+
|
|
74
95
|
@pytest.mark.anyio
|
|
75
96
|
async def test_valid_credentials_returns_token_pair(self, anon_client, users_db):
|
|
76
97
|
await _seed_user(users_db)
|
|
@@ -80,7 +101,7 @@ class TestTokenLogin:
|
|
|
80
101
|
)
|
|
81
102
|
assert resp.status_code == 200
|
|
82
103
|
body = resp.json()
|
|
83
|
-
assert body["token_type"] == "
|
|
104
|
+
assert body["token_type"] == "Bearer"
|
|
84
105
|
assert body["access_token"]
|
|
85
106
|
assert body["refresh_token"]
|
|
86
107
|
assert body["expires_in"] > 0
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import uuid
|
|
6
6
|
from datetime import UTC, datetime
|
|
7
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
7
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
from fastapi_users import exceptions
|
|
@@ -51,6 +51,8 @@ def fake_user():
|
|
|
51
51
|
def fake_user_db():
|
|
52
52
|
db = MagicMock()
|
|
53
53
|
db.update = AsyncMock(return_value=None)
|
|
54
|
+
db.session.flush = AsyncMock()
|
|
55
|
+
db.session.commit = AsyncMock()
|
|
54
56
|
return db
|
|
55
57
|
|
|
56
58
|
|
|
@@ -125,17 +127,53 @@ class TestOnAfterRegister:
|
|
|
125
127
|
# Pre-verified so the manager doesn't try to send a verify email.
|
|
126
128
|
fake_user.is_verified = True
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
with patch("users.manager.attach_default_role", AsyncMock()):
|
|
131
|
+
await manager.on_after_register(fake_user, request)
|
|
129
132
|
|
|
130
133
|
(event,) = bus.assert_published(UserRegistered)
|
|
131
134
|
assert event.user_id == fake_user.id
|
|
132
135
|
assert event.email == fake_user.email
|
|
133
136
|
|
|
134
137
|
@pytest.mark.anyio
|
|
135
|
-
async def
|
|
136
|
-
|
|
138
|
+
async def test_request_bound_registration_gets_the_user_role(
|
|
139
|
+
self, manager, fake_user, fake_user_db
|
|
140
|
+
):
|
|
141
|
+
"""GH #488: a signup with no role holds no permissions at all."""
|
|
142
|
+
from users.models import UserRole
|
|
143
|
+
|
|
144
|
+
request = MagicMock()
|
|
145
|
+
request.state = MagicMock(spec=[]) # a local signup, not OAuth
|
|
146
|
+
request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
|
|
147
|
+
fake_user.is_verified = True
|
|
148
|
+
role = MagicMock(id=uuid.uuid4())
|
|
149
|
+
fake_user_db.session.execute = AsyncMock(
|
|
150
|
+
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=role))
|
|
151
|
+
)
|
|
152
|
+
await manager.on_after_register(fake_user, request)
|
|
153
|
+
|
|
154
|
+
(added,) = fake_user_db.session.add.call_args.args
|
|
155
|
+
assert isinstance(added, UserRole)
|
|
156
|
+
assert (added.user_id, added.role_id) == (fake_user.id, role.id)
|
|
157
|
+
|
|
158
|
+
@pytest.mark.anyio
|
|
159
|
+
async def test_a_deleted_user_role_is_not_recreated(self, manager, fake_user, fake_user_db):
|
|
160
|
+
"""An admin who removed the role meant it; a public signup must not undo that."""
|
|
161
|
+
request = MagicMock()
|
|
162
|
+
request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
|
|
163
|
+
fake_user.is_verified = True
|
|
164
|
+
fake_user_db.session.execute = AsyncMock(
|
|
165
|
+
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None))
|
|
166
|
+
)
|
|
167
|
+
await manager.on_after_register(fake_user, request)
|
|
168
|
+
fake_user_db.session.add.assert_not_called()
|
|
169
|
+
|
|
170
|
+
@pytest.mark.anyio
|
|
171
|
+
async def test_without_request_skips_publish(self, manager, fake_user, fake_user_db):
|
|
172
|
+
"""CLI/bootstrap and admin flows pass request=None — no crash, no role:
|
|
173
|
+
the admin picks the roles there, and may pick none."""
|
|
137
174
|
fake_user.is_verified = True
|
|
138
175
|
await manager.on_after_register(fake_user, None)
|
|
176
|
+
fake_user_db.session.add.assert_not_called()
|
|
139
177
|
|
|
140
178
|
|
|
141
179
|
class TestOnAfterForgotPassword:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The ``Authorization`` scheme is case-insensitive (RFC 9110 §11.1, GH #533).
|
|
2
|
+
|
|
3
|
+
``/auth/token`` used to answer ``token_type="bearer"``, and a client that built
|
|
4
|
+
its header from that value sent ``bearer <token>`` — which the provider refused.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
|
|
12
|
+
_TOKEN = "/api/users/auth/token"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _request(app, header: str) -> Request:
|
|
16
|
+
return Request(
|
|
17
|
+
{
|
|
18
|
+
"type": "http",
|
|
19
|
+
"method": "GET",
|
|
20
|
+
"path": "/api/anything",
|
|
21
|
+
"headers": [(b"authorization", header.encode())],
|
|
22
|
+
"session": {},
|
|
23
|
+
"app": app,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.anyio
|
|
29
|
+
@pytest.mark.parametrize("scheme", ["Bearer", "bearer", "BEARER", " bearer "])
|
|
30
|
+
async def test_any_scheme_casing_authenticates(users_app, anon_client, scheme):
|
|
31
|
+
from users.provider import UsersAuthProvider
|
|
32
|
+
|
|
33
|
+
resp = await anon_client.post(
|
|
34
|
+
_TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
35
|
+
)
|
|
36
|
+
assert resp.status_code == 200, resp.text
|
|
37
|
+
body = resp.json()
|
|
38
|
+
assert body["token_type"] == "Bearer"
|
|
39
|
+
|
|
40
|
+
provider = UsersAuthProvider()
|
|
41
|
+
request = _request(users_app, f"{scheme} {body['access_token']}")
|
|
42
|
+
assert provider.is_bearer_request(request)
|
|
43
|
+
ctx = await provider.resolve_user(request)
|
|
44
|
+
assert ctx is not None
|
|
45
|
+
assert ctx.email == "admin@example.com"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.anyio
|
|
49
|
+
async def test_another_scheme_is_not_a_bearer_request(users_app):
|
|
50
|
+
from users.provider import UsersAuthProvider
|
|
51
|
+
|
|
52
|
+
request = _request(users_app, "Basic dXNlcjpwYXNz")
|
|
53
|
+
assert not UsersAuthProvider().is_bearer_request(request)
|
|
54
|
+
assert await UsersAuthProvider().resolve_user(request) is None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Which registrations get the standard ``user`` role (GH #488 review).
|
|
2
|
+
|
|
3
|
+
OAuth provisioning is not gated by ``allow_signup``, so the role it attaches
|
|
4
|
+
must be: with signups closed, an account any provider user can create holds no
|
|
5
|
+
role until an admin grants one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from users.constants import OAUTH_REGISTRATION_REQUEST_FLAG
|
|
16
|
+
from users.manager import UserManager
|
|
17
|
+
from users.models import UserRole
|
|
18
|
+
from users.settings import UsersSettings
|
|
19
|
+
|
|
20
|
+
_ROLE_ID = uuid.uuid4()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _manager(*, allow_signup: bool) -> UserManager:
|
|
24
|
+
settings = UsersSettings(
|
|
25
|
+
verification_token_secret="test-verify-secret-at-least-32-bytes-long",
|
|
26
|
+
reset_password_token_secret="test-reset-secret-at-least-32-bytes-long",
|
|
27
|
+
allow_signup=allow_signup,
|
|
28
|
+
)
|
|
29
|
+
user_db = MagicMock()
|
|
30
|
+
user_db.update = AsyncMock()
|
|
31
|
+
user_db.session.flush = AsyncMock()
|
|
32
|
+
user_db.session.commit = AsyncMock()
|
|
33
|
+
user_db.session.execute = AsyncMock(
|
|
34
|
+
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=MagicMock(id=_ROLE_ID)))
|
|
35
|
+
)
|
|
36
|
+
return UserManager(user_db, MagicMock(send_verification=AsyncMock()), settings)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _request(*, oauth: bool) -> MagicMock:
|
|
40
|
+
request = MagicMock()
|
|
41
|
+
request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
|
|
42
|
+
request.state = SimpleNamespace(**({OAUTH_REGISTRATION_REQUEST_FLAG: True} if oauth else {}))
|
|
43
|
+
return request
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _user() -> MagicMock:
|
|
47
|
+
return MagicMock(id=uuid.uuid4(), email="new@example.com", is_verified=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _added_roles(manager: UserManager) -> list[UserRole]:
|
|
51
|
+
return [
|
|
52
|
+
c.args[0]
|
|
53
|
+
for c in manager.user_db.session.add.call_args_list
|
|
54
|
+
if isinstance(c.args[0], UserRole)
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.anyio
|
|
59
|
+
async def test_oauth_provisioning_with_signup_closed_gets_no_role():
|
|
60
|
+
manager = _manager(allow_signup=False)
|
|
61
|
+
await manager.on_after_register(_user(), _request(oauth=True))
|
|
62
|
+
assert _added_roles(manager) == []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.anyio
|
|
66
|
+
async def test_oauth_provisioning_with_signup_open_gets_the_user_role():
|
|
67
|
+
manager = _manager(allow_signup=True)
|
|
68
|
+
user = _user()
|
|
69
|
+
await manager.on_after_register(user, _request(oauth=True))
|
|
70
|
+
(link,) = _added_roles(manager)
|
|
71
|
+
assert (link.user_id, link.role_id) == (user.id, _ROLE_ID)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.anyio
|
|
75
|
+
async def test_local_registration_gets_the_user_role():
|
|
76
|
+
# /register itself 404s while signup is closed, so reaching the hook
|
|
77
|
+
# means the operator opened it.
|
|
78
|
+
manager = _manager(allow_signup=True)
|
|
79
|
+
user = _user()
|
|
80
|
+
await manager.on_after_register(user, _request(oauth=False))
|
|
81
|
+
(link,) = _added_roles(manager)
|
|
82
|
+
assert (link.user_id, link.role_id) == (user.id, _ROLE_ID)
|
|
@@ -81,7 +81,6 @@ async def test_authenticated_request_sets_user_context(db_state, mw_active_user)
|
|
|
81
81
|
assert data["user"]["email"] == "middleware-test@example.com"
|
|
82
82
|
assert data["user"]["name"] == "Middleware Tester"
|
|
83
83
|
assert data["user"]["roles"] == ["admin"]
|
|
84
|
-
assert data["user"]["tenant_id"] == "acme"
|
|
85
84
|
|
|
86
85
|
|
|
87
86
|
# ---------------------------------------------------------------------------
|