simple-module-users 0.0.20__tar.gz → 0.0.22__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/pyproject.toml +6 -6
  3. simple_module_users-0.0.22/tests/test_external_users.py +228 -0
  4. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_mailer.py +50 -0
  5. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/api.py +10 -1
  6. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/components/UserRow.tsx +9 -1
  7. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/queries.py +2 -0
  8. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/service.py +13 -2
  9. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/token_api.py +11 -1
  10. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/backend.py +1 -1
  11. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/constants.py +6 -0
  12. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/contracts/schemas.py +2 -0
  13. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/exceptions.py +12 -0
  14. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/mailer/__init__.py +19 -2
  15. simple_module_users-0.0.22/users/mailer/console.py +43 -0
  16. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/mailer/smtp.py +17 -6
  17. simple_module_users-0.0.22/users/mailer/templates/invite.txt +7 -0
  18. simple_module_users-0.0.22/users/mailer/templates/reset_password.txt +7 -0
  19. simple_module_users-0.0.22/users/mailer/templates/verify_email.txt +7 -0
  20. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/manager.py +53 -1
  21. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/user.py +12 -2
  22. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/module.py +10 -2
  23. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/oauth/api.py +5 -0
  24. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Edit.tsx +20 -0
  25. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/components/AccountStatusCard.tsx +36 -21
  26. simple_module_users-0.0.20/users/mailer/console.py +0 -27
  27. simple_module_users-0.0.20/users/mailer/templates/invite.txt +0 -1
  28. simple_module_users-0.0.20/users/mailer/templates/reset_password.txt +0 -1
  29. simple_module_users-0.0.20/users/mailer/templates/verify_email.txt +0 -1
  30. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/.gitignore +0 -0
  31. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/LICENSE +0 -0
  32. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/README.md +0 -0
  33. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/package.json +0 -0
  34. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/.gitkeep +0 -0
  35. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/_middleware_support.py +0 -0
  36. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/conftest.py +0 -0
  37. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_access_token_model.py +0 -0
  38. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_admin.py +0 -0
  39. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_admin_crud.py +0 -0
  40. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_admin_filters.py +0 -0
  41. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_auth.py +0 -0
  42. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_backend.py +0 -0
  43. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_bootstrap.py +0 -0
  44. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_bootstrap_resolution.py +0 -0
  45. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_cli.py +0 -0
  46. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_constants.py +0 -0
  47. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_db_adapter.py +0 -0
  48. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_invite_flow.py +0 -0
  49. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_invite_reuse.py +0 -0
  50. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_negative_authz.py +0 -0
  51. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_oauth.py +0 -0
  52. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_oauth_routes.py +0 -0
  53. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_rate_limit.py +0 -0
  54. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_role_model.py +0 -0
  55. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_service_admin.py +0 -0
  56. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_service_admin_crud.py +0 -0
  57. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_settings.py +0 -0
  58. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_token_api.py +0 -0
  59. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_manager.py +0 -0
  60. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_model.py +0 -0
  61. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_role_model.py +0 -0
  62. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_service.py +0 -0
  63. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_deps.py +0 -0
  64. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_middleware.py +0 -0
  65. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_middleware_public_paths.py +0 -0
  66. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_middleware_resolvers.py +0 -0
  67. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_provider.py +0 -0
  68. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_views.py +0 -0
  69. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_views_admin.py +0 -0
  70. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tsconfig.json +0 -0
  71. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/__init__.py +0 -0
  72. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/__init__.py +0 -0
  73. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/components/IndexFilters.tsx +0 -0
  74. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/components/RolesTab.tsx +0 -0
  75. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/views.py +0 -0
  76. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/__init__.py +0 -0
  77. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/api.py +0 -0
  78. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/rate_limit.py +0 -0
  79. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/views.py +0 -0
  80. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/bootstrap.py +0 -0
  81. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/cli.py +0 -0
  82. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/contracts/__init__.py +0 -0
  83. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/contracts/events.py +0 -0
  84. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/db_adapter.py +0 -0
  85. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/deps.py +0 -0
  86. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/mailer/templates/.gitkeep +0 -0
  87. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/middleware.py +0 -0
  88. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/__init__.py +0 -0
  89. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/_base.py +0 -0
  90. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/access_token.py +0 -0
  91. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/oauth_account.py +0 -0
  92. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/refresh_token.py +0 -0
  93. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/role.py +0 -0
  94. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/user_role.py +0 -0
  95. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/oauth/__init__.py +0 -0
  96. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/oauth/providers.py +0 -0
  97. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/.gitkeep +0 -0
  98. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/AcceptInvite.tsx +0 -0
  99. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/ForgotPassword.tsx +0 -0
  100. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Login.tsx +0 -0
  101. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Profile.tsx +0 -0
  102. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Register.tsx +0 -0
  103. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/ResetPassword.tsx +0 -0
  104. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Create.tsx +0 -0
  105. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Index.tsx +0 -0
  106. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Invite.tsx +0 -0
  107. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/components/DangerZone.tsx +0 -0
  108. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/components/DetailsCard.tsx +0 -0
  109. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/VerifyEmail.tsx +0 -0
  110. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/provider.py +0 -0
  111. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/py.typed +0 -0
  112. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/roles_cache.py +0 -0
  113. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/settings.py +0 -0
  114. {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_users
3
- Version: 0.0.20
3
+ Version: 0.0.22
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.20
28
- Requires-Dist: simple-module-core==0.0.20
29
- Requires-Dist: simple-module-db==0.0.20
30
- Requires-Dist: simple-module-hosting==0.0.20
31
- Requires-Dist: simple-module-settings==0.0.20
27
+ Requires-Dist: simple-module-auth==0.0.22
28
+ Requires-Dist: simple-module-core==0.0.22
29
+ Requires-Dist: simple-module-db==0.0.22
30
+ Requires-Dist: simple-module-hosting==0.0.22
31
+ Requires-Dist: simple-module-settings==0.0.22
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.20"
3
+ version = "0.0.22"
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.20",
25
- "simple_module_db==0.0.20",
26
- "simple_module_hosting==0.0.20",
27
- "simple_module_settings==0.0.20",
28
- "simple_module_auth==0.0.20",
24
+ "simple_module_core==0.0.22",
25
+ "simple_module_db==0.0.22",
26
+ "simple_module_hosting==0.0.22",
27
+ "simple_module_settings==0.0.22",
28
+ "simple_module_auth==0.0.22",
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,228 @@
1
+ """External (SSO) user behaviour: null password, marking, and guards.
2
+
3
+ Covers the feature added on top of the OAuth/OIDC login flow:
4
+ - a *new* OAuth login provisions an external user (null password, marked),
5
+ - an OAuth login that *links* to an existing password account leaves it local,
6
+ - external users cannot password-login or be sent a reset link,
7
+ - admins can still assign roles to external users like any other user,
8
+ - the admin list surfaces ``is_external``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from urllib.parse import parse_qs, urlparse
15
+
16
+ import pytest
17
+ from fastapi_users.password import PasswordHelper
18
+ from sqlalchemy import select
19
+ from users.models import Role, User
20
+
21
+ _pw = PasswordHelper()
22
+
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
+ async def _get_user_by_email(db, email: str) -> User:
65
+ return (await db.execute(select(User).where(User.email == email))).scalar_one()
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Provisioning
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ @pytest.mark.anyio
74
+ async def test_new_oauth_login_creates_external_user(users_app, anon_client, users_db):
75
+ _install_fake_provider(users_app, "ms-oid-1", "sso-new@example.com")
76
+
77
+ assert await _run_oauth_login(anon_client) == 303
78
+
79
+ user = await _get_user_by_email(users_db, "sso-new@example.com")
80
+ assert user.is_external is True
81
+ assert user.hashed_password is None
82
+ assert user.is_verified is True
83
+
84
+
85
+ @pytest.mark.anyio
86
+ async def test_oauth_login_links_existing_password_user_stays_local(
87
+ users_app, anon_client, users_db
88
+ ):
89
+ existing = User(
90
+ id=uuid.uuid4(),
91
+ email="local@example.com",
92
+ hashed_password=_pw.hash("SecurePass1!"),
93
+ is_active=True,
94
+ is_verified=True,
95
+ )
96
+ users_db.add(existing)
97
+ await users_db.commit()
98
+
99
+ _install_fake_provider(users_app, "ms-oid-2", "local@example.com")
100
+ assert await _run_oauth_login(anon_client) == 303
101
+
102
+ user = await _get_user_by_email(users_db, "local@example.com")
103
+ assert user.is_external is False
104
+ assert user.hashed_password is not None # password retained
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Credential guards
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ async def _seed_external_user(db, email: str = "ext@example.com") -> User:
113
+ user = User(
114
+ id=uuid.uuid4(),
115
+ email=email,
116
+ hashed_password=None,
117
+ is_active=True,
118
+ is_verified=True,
119
+ is_external=True,
120
+ )
121
+ db.add(user)
122
+ await db.commit()
123
+ return user
124
+
125
+
126
+ @pytest.mark.anyio
127
+ async def test_external_user_cannot_password_login(users_app, anon_client, users_db):
128
+ await _seed_external_user(users_db, "ext-login@example.com")
129
+
130
+ resp = await anon_client.post(
131
+ "/api/users/auth/login",
132
+ data={"username": "ext-login@example.com", "password": "anything-at-all"},
133
+ )
134
+ assert resp.status_code == 400
135
+ assert resp.json()["detail"] == "LOGIN_BAD_CREDENTIALS"
136
+
137
+
138
+ @pytest.mark.anyio
139
+ async def test_external_user_cannot_bearer_token_login(users_app, anon_client, users_db):
140
+ """The bearer-token login path must reject external users cleanly (401),
141
+ not 500 on a None-hash verify, and without a timing/error enumeration leak."""
142
+ await _seed_external_user(users_db, "ext-token@example.com")
143
+
144
+ resp = await anon_client.post(
145
+ "/api/users/auth/token",
146
+ json={"email": "ext-token@example.com", "password": "anything-at-all"},
147
+ )
148
+ assert resp.status_code == 401
149
+ assert resp.json()["detail"] == "Invalid credentials"
150
+
151
+
152
+ @pytest.mark.anyio
153
+ async def test_admin_reset_link_rejected_for_external_user(admin_client, users_db):
154
+ user = await _seed_external_user(users_db, "ext-reset@example.com")
155
+
156
+ resp = await admin_client.post(f"/api/users/admin/{user.id}/reset-password-link")
157
+ assert resp.status_code == 409
158
+
159
+
160
+ @pytest.mark.anyio
161
+ async def test_forgot_password_is_noop_for_external_user(users_app):
162
+ from users.db_adapter import UserDatabaseWithRoles
163
+ from users.manager import UserManager
164
+ from users.models import OAuthAccount
165
+
166
+ async with users_app.state.sm.db.session_factory() as session:
167
+ user_db = UserDatabaseWithRoles(session, User, OAuthAccount)
168
+ manager = UserManager(user_db, users_app.state.users.mailer, users_app.state.users.settings)
169
+
170
+ sent: list[str] = []
171
+
172
+ async def _spy(user, token, request=None):
173
+ sent.append(user.email)
174
+
175
+ manager.on_after_forgot_password = _spy
176
+
177
+ external = User(
178
+ id=uuid.uuid4(),
179
+ email="ext-forgot@example.com",
180
+ hashed_password=None,
181
+ is_active=True,
182
+ is_verified=True,
183
+ is_external=True,
184
+ )
185
+ local = User(
186
+ id=uuid.uuid4(),
187
+ email="local-forgot@example.com",
188
+ hashed_password=_pw.hash("SecurePass1!"),
189
+ is_active=True,
190
+ is_verified=True,
191
+ )
192
+
193
+ await manager.forgot_password(external)
194
+ assert sent == [] # SSO account: no reset email
195
+
196
+ await manager.forgot_password(local)
197
+ assert sent == ["local-forgot@example.com"] # control: local account still works
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Roles + admin visibility (external users are managed like normal users)
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ @pytest.mark.anyio
206
+ async def test_admin_can_assign_role_to_external_user(admin_client, users_db):
207
+ user = await _seed_external_user(users_db, "ext-role@example.com")
208
+ users_db.add(Role(id=uuid.uuid4(), name="member", description="Member"))
209
+ await users_db.commit()
210
+
211
+ resp = await admin_client.put(
212
+ f"/api/users/admin/{user.id}/roles",
213
+ json={"role_names": ["member"]},
214
+ )
215
+ assert resp.status_code == 200
216
+ assert "member" in resp.json()["roles"]
217
+
218
+
219
+ @pytest.mark.anyio
220
+ async def test_admin_list_exposes_is_external(admin_client, users_db):
221
+ await _seed_external_user(users_db, "ext-list@example.com")
222
+
223
+ resp = await admin_client.get("/api/users/admin?per_page=100")
224
+ assert resp.status_code == 200
225
+ rows = {r["email"]: r for r in resp.json()}
226
+ assert rows["ext-list@example.com"]["is_external"] is True
227
+ # The seeded admin is a local account.
228
+ assert rows["admin@example.com"]["is_external"] is False
@@ -78,3 +78,53 @@ async def test_base_url_trailing_slash_stripped(caplog):
78
78
  link = caplog.records[0].link # type: ignore[attr-defined]
79
79
  assert not link.startswith("http://localhost:8000//")
80
80
  assert link == "http://localhost:8000/users/verify?token=tok"
81
+
82
+
83
+ @pytest.mark.anyio
84
+ async def test_console_logs_configured_app_name(caplog):
85
+ """A supplied provider brands the console log; otherwise the default."""
86
+ from users.mailer.console import ConsoleMailer
87
+
88
+ branded = ConsoleMailer(base_url="http://localhost:8000", app_name_provider=lambda: "Acme")
89
+ default = ConsoleMailer(base_url="http://localhost:8000")
90
+
91
+ with caplog.at_level(logging.INFO, logger="users.mailer"):
92
+ await branded.send_invite("a@b.com", "tok", "Alice")
93
+ await default.send_verification("a@b.com", "tok")
94
+
95
+ assert caplog.records[0].app_name == "Acme" # type: ignore[attr-defined]
96
+ assert caplog.records[1].app_name == "SimpleModule" # type: ignore[attr-defined]
97
+
98
+
99
+ @pytest.mark.anyio
100
+ async def test_smtp_emails_carry_the_app_name(monkeypatch):
101
+ """SMTP subjects + bodies include the live app name (and keep the link)."""
102
+ from users.mailer.smtp import SmtpMailer
103
+
104
+ captured: list[dict[str, str]] = []
105
+
106
+ async def fake_send(self, to: str, subject: str, body: str) -> None:
107
+ captured.append({"to": to, "subject": subject, "body": body})
108
+
109
+ monkeypatch.setattr(SmtpMailer, "_send", fake_send)
110
+ mailer = SmtpMailer(
111
+ host="smtp.test",
112
+ port=25,
113
+ username="",
114
+ password="",
115
+ from_address="from@test",
116
+ use_tls=False,
117
+ base_url="http://localhost:8000",
118
+ app_name_provider=lambda: "Acme",
119
+ )
120
+
121
+ await mailer.send_invite("new@x.com", "tok", "Alice")
122
+ await mailer.send_verification("v@x.com", "vtok")
123
+ await mailer.send_password_reset("r@x.com", "rtok")
124
+
125
+ invite, verify, reset = captured
126
+ assert "Acme" in invite["subject"] and "Alice" in invite["subject"]
127
+ assert "Acme" in invite["body"]
128
+ assert "http://localhost:8000/users/invite/accept?token=tok" in invite["body"]
129
+ assert "Acme" in verify["subject"] and "Acme" in verify["body"]
130
+ assert "Acme" in reset["subject"] and "Acme" in reset["body"]
@@ -28,7 +28,11 @@ from users.contracts.schemas import (
28
28
  UserListItem,
29
29
  )
30
30
  from users.deps import get_event_bus, get_mailer, get_user_service
31
- from users.exceptions import EmailAlreadyExistsError, UserNotFoundError
31
+ from users.exceptions import (
32
+ EmailAlreadyExistsError,
33
+ ExternalUserNoPasswordError,
34
+ UserNotFoundError,
35
+ )
32
36
 
33
37
  admin_router = APIRouter(
34
38
  prefix="/admin",
@@ -243,4 +247,9 @@ async def admin_reset_password_link(
243
247
  link = await service.generate_reset_link(user_id, base_url)
244
248
  except UserNotFoundError:
245
249
  raise HTTPException(status_code=404, detail="User not found") from None
250
+ except ExternalUserNoPasswordError:
251
+ raise HTTPException(
252
+ status_code=409,
253
+ detail="External (SSO) users have no password to reset",
254
+ ) from None
246
255
  return PasswordResetLink(link=link)
@@ -10,6 +10,7 @@ export interface UserListItem {
10
10
  full_name: string | null;
11
11
  is_active: boolean;
12
12
  is_verified: boolean;
13
+ is_external: boolean;
13
14
  last_login_at: string | null;
14
15
  created_at: string | null;
15
16
  roles: string[];
@@ -63,7 +64,14 @@ export function UserRow({ user }: { user: UserListItem }) {
63
64
  {user.roles.length > 0 ? user.roles.join(', ') : '—'}
64
65
  </TableCell>
65
66
  <TableCell className="hidden sm:table-cell">
66
- <StatusBadge user={user} />
67
+ <div className="flex items-center gap-1.5">
68
+ <StatusBadge user={user} />
69
+ {user.is_external && (
70
+ <Badge variant="outline" className="border-violet-200 bg-violet-50 text-violet-700">
71
+ SSO
72
+ </Badge>
73
+ )}
74
+ </div>
67
75
  </TableCell>
68
76
  <TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
69
77
  {user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : '—'}
@@ -40,6 +40,7 @@ class _UserServiceBase:
40
40
  full_name=user.full_name,
41
41
  is_active=user.is_active,
42
42
  is_verified=user.is_verified,
43
+ is_external=user.is_external,
43
44
  disabled_at=user.disabled_at,
44
45
  last_login_at=user.last_login_at,
45
46
  created_at=user.created_at,
@@ -103,6 +104,7 @@ class _UserServiceBase:
103
104
  User.full_name,
104
105
  User.is_active,
105
106
  User.is_verified,
107
+ User.is_external,
106
108
  User.disabled_at,
107
109
  User.last_login_at,
108
110
  User.created_at,
@@ -11,7 +11,11 @@ from sqlalchemy.orm import noload
11
11
 
12
12
  from users.admin.queries import _UserServiceBase
13
13
  from users.contracts.schemas import UserCreate
14
- from users.exceptions import EmailAlreadyExistsError, UserNotFoundError
14
+ from users.exceptions import (
15
+ EmailAlreadyExistsError,
16
+ ExternalUserNoPasswordError,
17
+ UserNotFoundError,
18
+ )
15
19
  from users.models import OAuthAccount, RefreshToken, User, UserAccessToken, UserRole
16
20
 
17
21
 
@@ -205,8 +209,15 @@ class UserService(_UserServiceBase):
205
209
  return user
206
210
 
207
211
  async def generate_reset_link(self, user_id: uuid.UUID, base_url: str) -> str:
208
- """Build an admin-copyable password-reset URL. No email side-effect."""
212
+ """Build an admin-copyable password-reset URL. No email side-effect.
213
+
214
+ Raises ``ExternalUserNoPasswordError`` for SSO users — they have no
215
+ local password, so a reset link is meaningless (and would crash on the
216
+ null-hash fingerprint).
217
+ """
209
218
  user = await self._require_user(user_id)
219
+ if user.is_external:
220
+ raise ExternalUserNoPasswordError(user_id)
210
221
 
211
222
  token = await self._manager.generate_reset_password_token(user)
212
223
  return f"{base_url.rstrip('/')}/users/reset-password?token={token}"
@@ -62,7 +62,17 @@ async def token_login(
62
62
 
63
63
  stmt = select(User).where(User.email == body.email)
64
64
  user = (await db.execute(stmt)).scalar_one_or_none()
65
- if user is None or not user.is_active or user.disabled_at is not None:
65
+ if (
66
+ user is None
67
+ or not user.is_active
68
+ or user.disabled_at is not None
69
+ # External (SSO) users have ``hashed_password is None`` — there's no
70
+ # local password to verify. Treat them like a missing user: verifying
71
+ # against a None hash would raise (500) and the instant failure would
72
+ # leak that the account is SSO-only. The session login is guarded the
73
+ # same way in ``UserManager.authenticate``.
74
+ or user.hashed_password is None
75
+ ):
66
76
  # Constant-time: run bcrypt on a dummy hash to prevent timing-based
67
77
  # email enumeration (existing user + wrong password takes ~50ms for
68
78
  # bcrypt; missing user would be instant without this).
@@ -82,4 +82,4 @@ def reconfigure_cookie_transport(
82
82
  transport.cookie_name = settings.cookie_name
83
83
  transport.cookie_max_age = settings.cookie_max_age_seconds
84
84
  transport.cookie_secure = settings.cookie_secure
85
- transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment] # ty: ignore[invalid-assignment]
85
+ transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment]
@@ -20,6 +20,12 @@ PERM_USERS_SELF_PROFILE = "users.self.profile"
20
20
  # Session keys
21
21
  SESSION_USER_ID_KEY = "user_id"
22
22
 
23
+ # request.state flag set by the OAuth callback before find-or-create so the
24
+ # manager's on_after_register hook can mark *newly provisioned* OAuth users as
25
+ # external (null password). on_after_register fires only for new users, so the
26
+ # flag is ignored when an OAuth login merely links to an existing account.
27
+ OAUTH_REGISTRATION_REQUEST_FLAG = "users_oauth_registration"
28
+
23
29
  # Admin list-endpoint allowed filter/sort values
24
30
  ALLOWED_STATUS = frozenset({"active", "disabled"})
25
31
  ALLOWED_VERIFIED = frozenset({"yes", "no"})
@@ -25,6 +25,7 @@ class UserRead(CreateUpdateDictModel, SQLModel):
25
25
  is_active: bool = True
26
26
  is_superuser: bool = False
27
27
  is_verified: bool = False
28
+ is_external: bool = False
28
29
  full_name: str | None = None
29
30
  tenant_id: str | None = None
30
31
  disabled_at: datetime | None = None
@@ -74,6 +75,7 @@ class UserListItem(SQLModel):
74
75
  full_name: str | None = None
75
76
  is_active: bool
76
77
  is_verified: bool
78
+ is_external: bool = False
77
79
  disabled_at: datetime | None = None
78
80
  last_login_at: datetime | None = None
79
81
  created_at: datetime | None = None
@@ -24,3 +24,15 @@ class EmailAlreadyExistsError(Exception):
24
24
  def __init__(self, email: str) -> None:
25
25
  super().__init__(f"Email {email} already in use")
26
26
  self.email = email
27
+
28
+
29
+ class ExternalUserNoPasswordError(Exception):
30
+ """Raised when a password-credential action targets an external (SSO) user.
31
+
32
+ External users have no local password, so password reset / set has no
33
+ meaning for them. Endpoints translate this into a 4xx.
34
+ """
35
+
36
+ def __init__(self, user_id: uuid.UUID) -> None:
37
+ super().__init__(f"User {user_id} is external (SSO) and has no password")
38
+ self.user_id = user_id
@@ -2,10 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from typing import Protocol, runtime_checkable
6
7
 
7
8
  from users.settings import UsersSettings
8
9
 
10
+ #: Returns the live application name (e.g. from the branding module) so emails
11
+ #: are branded with the deployment's name rather than the framework default.
12
+ AppNameProvider = Callable[[], str]
13
+
14
+ #: Fallback app name when no provider is supplied (e.g. branding not installed).
15
+ DEFAULT_APP_NAME = "SimpleModule"
16
+
17
+
18
+ def default_app_name() -> str:
19
+ return DEFAULT_APP_NAME
20
+
9
21
 
10
22
  @runtime_checkable
11
23
  class Mailer(Protocol):
@@ -14,7 +26,11 @@ class Mailer(Protocol):
14
26
  async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: ...
15
27
 
16
28
 
17
- def build_mailer(settings: UsersSettings) -> Mailer:
29
+ def build_mailer(
30
+ settings: UsersSettings,
31
+ app_name_provider: AppNameProvider | None = None,
32
+ ) -> Mailer:
33
+ provider = app_name_provider or default_app_name
18
34
  if settings.mailer == "smtp":
19
35
  from users.mailer.smtp import SmtpMailer
20
36
 
@@ -26,8 +42,9 @@ def build_mailer(settings: UsersSettings) -> Mailer:
26
42
  from_address=settings.smtp_from,
27
43
  use_tls=settings.smtp_tls,
28
44
  base_url=settings.base_url,
45
+ app_name_provider=provider,
29
46
  )
30
47
 
31
48
  from users.mailer.console import ConsoleMailer
32
49
 
33
- return ConsoleMailer(base_url=settings.base_url)
50
+ return ConsoleMailer(base_url=settings.base_url, app_name_provider=provider)
@@ -0,0 +1,43 @@
1
+ """Console mailer — logs tokenized links for local development."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from users.mailer import AppNameProvider
10
+
11
+ logger = logging.getLogger("users.mailer")
12
+
13
+
14
+ class ConsoleMailer:
15
+ def __init__(self, base_url: str, app_name_provider: AppNameProvider | None = None) -> None:
16
+ self._base = base_url.rstrip("/")
17
+ from users.mailer import default_app_name
18
+
19
+ self._app_name = app_name_provider or default_app_name
20
+
21
+ async def send_verification(self, email: str, token: str) -> None:
22
+ link = f"{self._base}/users/verify?token={token}"
23
+ logger.info(
24
+ "users.verify.email", extra={"to": email, "link": link, "app_name": self._app_name()}
25
+ )
26
+
27
+ async def send_password_reset(self, email: str, token: str) -> None:
28
+ link = f"{self._base}/users/reset-password?token={token}"
29
+ logger.info(
30
+ "users.reset.email", extra={"to": email, "link": link, "app_name": self._app_name()}
31
+ )
32
+
33
+ async def send_invite(self, email: str, token: str, invited_by_name: str) -> None:
34
+ link = f"{self._base}/users/invite/accept?token={token}"
35
+ logger.info(
36
+ "users.invite.email",
37
+ extra={
38
+ "to": email,
39
+ "link": link,
40
+ "invited_by": invited_by_name,
41
+ "app_name": self._app_name(),
42
+ },
43
+ )
@@ -4,10 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import importlib.resources
6
6
  from email.message import EmailMessage
7
+ from typing import TYPE_CHECKING
7
8
 
8
9
  import aiosmtplib
9
10
  import jinja2
10
11
 
12
+ if TYPE_CHECKING:
13
+ from users.mailer import AppNameProvider
14
+
11
15
 
12
16
  def _load_template_env() -> jinja2.Environment:
13
17
  """Build a Jinja2 Environment pointed at the bundled templates directory."""
@@ -33,6 +37,7 @@ class SmtpMailer:
33
37
  from_address: str,
34
38
  use_tls: bool,
35
39
  base_url: str,
40
+ app_name_provider: AppNameProvider | None = None,
36
41
  ) -> None:
37
42
  self._host = host
38
43
  self._port = port
@@ -41,24 +46,30 @@ class SmtpMailer:
41
46
  self._from = from_address
42
47
  self._use_tls = use_tls
43
48
  self._base = base_url.rstrip("/")
49
+ from users.mailer import default_app_name
50
+
51
+ self._app_name = app_name_provider or default_app_name
44
52
 
45
53
  async def send_verification(self, email: str, token: str) -> None:
54
+ app = self._app_name()
46
55
  link = f"{self._base}/users/verify?token={token}"
47
56
  template = _template_env.get_template("verify_email.txt")
48
- body = template.render(link=link)
49
- await self._send(email, "Verify your email address", body)
57
+ body = template.render(link=link, app_name=app)
58
+ await self._send(email, f"Verify your email for {app}", body)
50
59
 
51
60
  async def send_password_reset(self, email: str, token: str) -> None:
61
+ app = self._app_name()
52
62
  link = f"{self._base}/users/reset-password?token={token}"
53
63
  template = _template_env.get_template("reset_password.txt")
54
- body = template.render(link=link)
55
- await self._send(email, "Reset your password", body)
64
+ body = template.render(link=link, app_name=app)
65
+ await self._send(email, f"Reset your {app} password", body)
56
66
 
57
67
  async def send_invite(self, email: str, token: str, invited_by_name: str) -> None:
68
+ app = self._app_name()
58
69
  link = f"{self._base}/users/invite/accept?token={token}"
59
70
  template = _template_env.get_template("invite.txt")
60
- body = template.render(link=link, invited_by_name=invited_by_name)
61
- await self._send(email, f"You've been invited by {invited_by_name}", body)
71
+ body = template.render(link=link, invited_by_name=invited_by_name, app_name=app)
72
+ await self._send(email, f"{invited_by_name} invited you to {app}", body)
62
73
 
63
74
  async def _send(self, to: str, subject: str, body: str) -> None:
64
75
  message = EmailMessage()
@@ -0,0 +1,7 @@
1
+ {{ invited_by_name }} invited you to join {{ app_name }}.
2
+
3
+ Accept the invitation: {{ link }}
4
+
5
+ If you weren't expecting this, you can safely ignore this email.
6
+
7
+ — The {{ app_name }} team
@@ -0,0 +1,7 @@
1
+ We received a request to reset your {{ app_name }} password.
2
+
3
+ Reset it here: {{ link }}
4
+
5
+ If you didn't request this, you can safely ignore this email — your password won't change.
6
+
7
+ — The {{ app_name }} team