simple-module-users 0.0.36__tar.gz → 0.0.37__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/.gitignore +1 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/PKG-INFO +6 -6
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/pyproject.toml +6 -6
- simple_module_users-0.0.37/tests/_users_oauth_support.py +84 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_auth_screens_props.py +7 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_external_users.py +6 -45
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_negative_authz.py +4 -1
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_token_api.py +22 -1
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_manager.py +42 -4
- 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.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.36 → simple_module_users-0.0.37}/tests-js/LoginForm.test.tsx +6 -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.36 → simple_module_users-0.0.37}/users/auth_local/api.py +4 -2
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/LoginForm.tsx +2 -2
- {simple_module_users-0.0.36 → 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.36 → simple_module_users-0.0.37}/users/auth_local/token_api.py +13 -3
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/views.py +19 -3
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/bootstrap.py +1 -1
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/locales/en.json +10 -4
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/manager.py +30 -4
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/user.py +5 -4
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/user_role.py +3 -2
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/oauth/api.py +53 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Login.tsx +7 -1
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Register.tsx +86 -34
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/provider.py +11 -4
- simple_module_users-0.0.37/users/registration.py +41 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/token_strategy.py +4 -1
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/LICENSE +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/README.md +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/package.json +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/conftest.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bearer_token_expiry.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_demo_account.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_demo_guard.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_demo_login.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_legacy_cached_tenant.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_login_redirect_root_mount.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_remember_me.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_revocation_broadcast.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_revocation.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_setup_lock.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_setup_wizard.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_views.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/tsconfig.json +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/__init__.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/api.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/bulk_invite.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/useUserRowActions.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/queries.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/recent_activity.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/resend_invite.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/service.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/admin/views.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/audit.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/demo_api.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/invite_preview.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/self_account.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/backend.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/cli.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/constants.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/demo.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/demo_guard.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/deps.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/exceptions.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/health.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/middleware.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/_base.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/models/role.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/module.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/AddPeople.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/useEditUserForm.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/components/useUserActions.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/py.typed +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/session_revocation.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/settings.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/setup.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/setup_action.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/shared_props.py +0 -0
- {simple_module_users-0.0.36 → simple_module_users-0.0.37}/users/startup.py +0 -0
- {simple_module_users-0.0.36 → 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.
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""OAuth sign-in creates an account only when ``allow_signup`` is on (GH #543).
|
|
2
|
+
|
|
3
|
+
With signups closed, an operator who enables SSO for staff must not open a
|
|
4
|
+
signup to the provider's whole user base. Accounts that already exist — linked
|
|
5
|
+
by OAuth account or associated by email — still sign in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from _users_oauth_support import (
|
|
12
|
+
find_user,
|
|
13
|
+
install_fake_provider,
|
|
14
|
+
oauth_callback,
|
|
15
|
+
seed_password_user,
|
|
16
|
+
set_allow_signup,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.anyio
|
|
21
|
+
async def test_signup_closed_refuses_a_new_identity(users_app, anon_client):
|
|
22
|
+
install_fake_provider(users_app, "ms-new", "stranger@example.com")
|
|
23
|
+
|
|
24
|
+
resp = await oauth_callback(anon_client)
|
|
25
|
+
|
|
26
|
+
assert resp.status_code == 403
|
|
27
|
+
assert resp.json()["detail"] == "OAUTH_SIGNUP_DISABLED"
|
|
28
|
+
assert users_app.state.users.settings.cookie_name not in resp.cookies
|
|
29
|
+
assert await find_user(users_app, "stranger@example.com") is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.anyio
|
|
33
|
+
async def test_signup_open_creates_the_account(users_app, anon_client):
|
|
34
|
+
set_allow_signup(users_app, True)
|
|
35
|
+
install_fake_provider(users_app, "ms-new", "newcomer@example.com")
|
|
36
|
+
|
|
37
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|
|
38
|
+
user = await find_user(users_app, "newcomer@example.com")
|
|
39
|
+
assert user is not None
|
|
40
|
+
assert user.is_external is True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.anyio
|
|
44
|
+
async def test_signup_closed_still_associates_an_existing_email(users_app, anon_client):
|
|
45
|
+
await seed_password_user(users_app, "staff@example.com", is_verified=True)
|
|
46
|
+
install_fake_provider(users_app, "ms-staff", "staff@example.com")
|
|
47
|
+
|
|
48
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.anyio
|
|
52
|
+
async def test_signup_closed_still_signs_in_a_linked_oauth_account(users_app, anon_client):
|
|
53
|
+
"""Matched by the linked OAuth account even when the provider's email moved."""
|
|
54
|
+
set_allow_signup(users_app, True)
|
|
55
|
+
install_fake_provider(users_app, "ms-linked", "before@example.com")
|
|
56
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|
|
57
|
+
|
|
58
|
+
set_allow_signup(users_app, False)
|
|
59
|
+
anon_client.cookies.clear()
|
|
60
|
+
install_fake_provider(users_app, "ms-linked", "after@example.com")
|
|
61
|
+
|
|
62
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|
|
63
|
+
assert await find_user(users_app, "after@example.com") is None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""OAuth never links by email to an unverified password account (GH #547).
|
|
2
|
+
|
|
3
|
+
With ``require_verification`` off anyone can register ``victim@corp.com`` with
|
|
4
|
+
a password and sign in. If the real owner later signs in with SSO, associating
|
|
5
|
+
by email would hand them — and the attacker — one shared account.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from _users_oauth_support import (
|
|
15
|
+
install_fake_provider,
|
|
16
|
+
oauth_callback,
|
|
17
|
+
seed_password_user,
|
|
18
|
+
set_allow_signup,
|
|
19
|
+
)
|
|
20
|
+
from fastapi_users.password import PasswordHelper
|
|
21
|
+
from sqlalchemy import select
|
|
22
|
+
from users.models import OAuthAccount, User
|
|
23
|
+
|
|
24
|
+
_EMAIL = "victim@example.com"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _links(app, user_id: uuid.UUID) -> list[OAuthAccount]:
|
|
28
|
+
async with app.state.sm.db.session_factory() as session:
|
|
29
|
+
stmt = select(OAuthAccount).where(OAuthAccount.user_id == user_id)
|
|
30
|
+
return list((await session.execute(stmt)).scalars())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.anyio
|
|
34
|
+
@pytest.mark.parametrize("allow_signup", [True, False])
|
|
35
|
+
async def test_unverified_password_account_is_not_linked(users_app, anon_client, allow_signup):
|
|
36
|
+
set_allow_signup(users_app, allow_signup)
|
|
37
|
+
user_id = await seed_password_user(users_app, _EMAIL, is_verified=False)
|
|
38
|
+
install_fake_provider(users_app, "ms-victim", _EMAIL)
|
|
39
|
+
|
|
40
|
+
resp = await oauth_callback(anon_client)
|
|
41
|
+
|
|
42
|
+
assert resp.status_code == 403
|
|
43
|
+
assert resp.json()["detail"] == "OAUTH_ACCOUNT_NOT_VERIFIED"
|
|
44
|
+
assert users_app.state.users.settings.cookie_name not in resp.cookies
|
|
45
|
+
assert await _links(users_app, user_id) == []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.anyio
|
|
49
|
+
async def test_verified_account_links(users_app, anon_client):
|
|
50
|
+
user_id = await seed_password_user(users_app, _EMAIL, is_verified=True)
|
|
51
|
+
install_fake_provider(users_app, "ms-owner", _EMAIL)
|
|
52
|
+
|
|
53
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|
|
54
|
+
assert [a.account_id for a in await _links(users_app, user_id)] == ["ms-owner"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.anyio
|
|
58
|
+
async def test_pending_invite_links(users_app, anon_client):
|
|
59
|
+
"""An invite's password is random and unknown, so nobody else holds the account."""
|
|
60
|
+
user_id = await seed_password_user(
|
|
61
|
+
users_app, _EMAIL, is_verified=False, invited_at=datetime.now(UTC)
|
|
62
|
+
)
|
|
63
|
+
install_fake_provider(users_app, "ms-invitee", _EMAIL)
|
|
64
|
+
|
|
65
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|
|
66
|
+
assert len(await _links(users_app, user_id)) == 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.anyio
|
|
70
|
+
async def test_already_linked_account_signs_in(users_app, anon_client):
|
|
71
|
+
"""Found by its OAuth link, not by email — verification is not asked for."""
|
|
72
|
+
set_allow_signup(users_app, True)
|
|
73
|
+
install_fake_provider(users_app, "ms-linked", "linked@example.com")
|
|
74
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|
|
75
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
76
|
+
user = (
|
|
77
|
+
await session.execute(select(User).where(User.email == "linked@example.com"))
|
|
78
|
+
).scalar_one()
|
|
79
|
+
user.is_verified = False
|
|
80
|
+
user.hashed_password = PasswordHelper().hash("SecurePass1!")
|
|
81
|
+
user.is_external = False
|
|
82
|
+
await session.commit()
|
|
83
|
+
|
|
84
|
+
anon_client.cookies.clear()
|
|
85
|
+
assert (await oauth_callback(anon_client)).status_code == 303
|