simple-module-users 0.0.16__tar.gz → 0.0.17__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 (103) hide show
  1. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/pyproject.toml +6 -6
  3. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/_middleware_support.py +4 -1
  4. simple_module_users-0.0.17/tests/test_token_api.py +191 -0
  5. simple_module_users-0.0.17/tests/test_users_provider.py +46 -0
  6. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/auth_local/api.py +12 -2
  7. simple_module_users-0.0.17/users/auth_local/token_api.py +159 -0
  8. simple_module_users-0.0.17/users/middleware.py +10 -0
  9. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/__init__.py +2 -0
  10. simple_module_users-0.0.17/users/models/refresh_token.py +22 -0
  11. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/module.py +5 -15
  12. simple_module_users-0.0.17/users/provider.py +130 -0
  13. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/settings.py +4 -0
  14. simple_module_users-0.0.16/users/middleware.py +0 -168
  15. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/.gitignore +0 -0
  16. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/LICENSE +0 -0
  17. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/README.md +0 -0
  18. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/package.json +0 -0
  19. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/.gitkeep +0 -0
  20. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/conftest.py +0 -0
  21. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_access_token_model.py +0 -0
  22. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_api_admin.py +0 -0
  23. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_api_admin_filters.py +0 -0
  24. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_api_auth.py +0 -0
  25. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_backend.py +0 -0
  26. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_bootstrap.py +0 -0
  27. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_bootstrap_resolution.py +0 -0
  28. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_cli.py +0 -0
  29. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_constants.py +0 -0
  30. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_db_adapter.py +0 -0
  31. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_invite_flow.py +0 -0
  32. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_invite_reuse.py +0 -0
  33. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_mailer.py +0 -0
  34. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_negative_authz.py +0 -0
  35. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_oauth.py +0 -0
  36. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_rate_limit.py +0 -0
  37. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_role_model.py +0 -0
  38. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_service_admin.py +0 -0
  39. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_settings.py +0 -0
  40. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_manager.py +0 -0
  41. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_model.py +0 -0
  42. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_role_model.py +0 -0
  43. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_service.py +0 -0
  44. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_deps.py +0 -0
  45. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_middleware.py +0 -0
  46. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_middleware_public_paths.py +0 -0
  47. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_middleware_resolvers.py +0 -0
  48. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_views.py +0 -0
  49. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_views_admin.py +0 -0
  50. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tsconfig.json +0 -0
  51. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/__init__.py +0 -0
  52. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/__init__.py +0 -0
  53. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/api.py +0 -0
  54. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/components/IndexFilters.tsx +0 -0
  55. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/components/RolesTab.tsx +0 -0
  56. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/components/UserRow.tsx +0 -0
  57. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/service.py +0 -0
  58. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/views.py +0 -0
  59. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/auth_local/__init__.py +0 -0
  60. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/auth_local/rate_limit.py +0 -0
  61. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/auth_local/views.py +0 -0
  62. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/backend.py +0 -0
  63. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/bootstrap.py +0 -0
  64. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/cli.py +0 -0
  65. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/constants.py +0 -0
  66. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/contracts/__init__.py +0 -0
  67. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/contracts/events.py +0 -0
  68. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/contracts/schemas.py +0 -0
  69. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/db_adapter.py +0 -0
  70. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/deps.py +0 -0
  71. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/exceptions.py +0 -0
  72. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/__init__.py +0 -0
  73. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/console.py +0 -0
  74. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/smtp.py +0 -0
  75. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/.gitkeep +0 -0
  76. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/invite.txt +0 -0
  77. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/reset_password.txt +0 -0
  78. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/verify_email.txt +0 -0
  79. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/manager.py +0 -0
  80. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/_base.py +0 -0
  81. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/access_token.py +0 -0
  82. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/oauth_account.py +0 -0
  83. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/role.py +0 -0
  84. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/user.py +0 -0
  85. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/user_role.py +0 -0
  86. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/oauth/__init__.py +0 -0
  87. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/oauth/api.py +0 -0
  88. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/oauth/providers.py +0 -0
  89. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/.gitkeep +0 -0
  90. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/AcceptInvite.tsx +0 -0
  91. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/ForgotPassword.tsx +0 -0
  92. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Login.tsx +0 -0
  93. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Profile.tsx +0 -0
  94. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Register.tsx +0 -0
  95. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/ResetPassword.tsx +0 -0
  96. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/Edit.tsx +0 -0
  97. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/Index.tsx +0 -0
  98. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/Invite.tsx +0 -0
  99. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  100. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/VerifyEmail.tsx +0 -0
  101. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/py.typed +0 -0
  102. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/roles_cache.py +0 -0
  103. {simple_module_users-0.0.16 → simple_module_users-0.0.17}/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.16
3
+ Version: 0.0.17
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.16
28
- Requires-Dist: simple-module-core==0.0.16
29
- Requires-Dist: simple-module-db==0.0.16
30
- Requires-Dist: simple-module-hosting==0.0.16
31
- Requires-Dist: simple-module-settings==0.0.16
27
+ Requires-Dist: simple-module-auth==0.0.17
28
+ Requires-Dist: simple-module-core==0.0.17
29
+ Requires-Dist: simple-module-db==0.0.17
30
+ Requires-Dist: simple-module-hosting==0.0.17
31
+ Requires-Dist: simple-module-settings==0.0.17
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.16"
3
+ version = "0.0.17"
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.16",
25
- "simple_module_db==0.0.16",
26
- "simple_module_hosting==0.0.16",
27
- "simple_module_settings==0.0.16",
28
- "simple_module_auth==0.0.16",
24
+ "simple_module_core==0.0.17",
25
+ "simple_module_db==0.0.17",
26
+ "simple_module_hosting==0.0.17",
27
+ "simple_module_settings==0.0.17",
28
+ "simple_module_auth==0.0.17",
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.
@@ -14,12 +14,12 @@ from types import SimpleNamespace
14
14
  from typing import Any
15
15
 
16
16
  import pytest
17
+ from auth.middleware import AuthMiddleware
17
18
  from fastapi import FastAPI, Request
18
19
  from simple_module_test import forge_session_cookie
19
20
  from starlette.middleware.sessions import SessionMiddleware
20
21
  from starlette.responses import JSONResponse
21
22
  from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
22
- from users.middleware import AuthMiddleware
23
23
 
24
24
  SECRET_KEY = "test-secret-key-for-session-middleware"
25
25
 
@@ -61,7 +61,10 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
61
61
 
62
62
  app = FastAPI()
63
63
  app.state.sm = SimpleNamespace(db=db_state)
64
+ from users.provider import UsersAuthProvider
65
+
64
66
  app.state.auth = AuthState(
67
+ auth_provider=UsersAuthProvider(),
65
68
  principal_resolvers=list(principal_resolvers or []),
66
69
  )
67
70
 
@@ -0,0 +1,191 @@
1
+ """Tests for bearer token endpoints: /api/users/auth/token*.
2
+
3
+ Covers: login via email+password, refresh rotation, revoke, and error paths.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import uuid
9
+
10
+ import pytest
11
+ from fastapi_users.password import PasswordHelper
12
+ from users.models import User
13
+
14
+ _pw = PasswordHelper()
15
+
16
+
17
+ def _hash(plain: str) -> str:
18
+ return _pw.hash(plain)
19
+
20
+
21
+ async def _seed_user(session, email="api@example.com", password="SecurePass1!"):
22
+ """Create a verified, active user for token tests."""
23
+ user = User(
24
+ id=uuid.uuid4(),
25
+ email=email,
26
+ hashed_password=_hash(password),
27
+ is_active=True,
28
+ is_superuser=False,
29
+ is_verified=True,
30
+ )
31
+ session.add(user)
32
+ await session.commit()
33
+ await session.refresh(user)
34
+ return user
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # POST /api/users/auth/token — login
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class TestTokenLogin:
43
+ @pytest.mark.anyio
44
+ async def test_invalid_email_returns_401(self, anon_client):
45
+ resp = await anon_client.post(
46
+ "/api/users/auth/token",
47
+ json={"email": "nobody@example.com", "password": "whatever"},
48
+ )
49
+ assert resp.status_code == 401
50
+ assert resp.json()["detail"] == "Invalid credentials"
51
+
52
+ @pytest.mark.anyio
53
+ async def test_wrong_password_returns_401(self, anon_client, users_db):
54
+ await _seed_user(users_db)
55
+ resp = await anon_client.post(
56
+ "/api/users/auth/token",
57
+ json={"email": "api@example.com", "password": "WRONG"},
58
+ )
59
+ assert resp.status_code == 401
60
+ assert resp.json()["detail"] == "Invalid credentials"
61
+
62
+ @pytest.mark.anyio
63
+ async def test_inactive_user_returns_401(self, anon_client, users_db):
64
+ user = await _seed_user(users_db, email="inactive@example.com")
65
+ user.is_active = False
66
+ await users_db.commit()
67
+
68
+ resp = await anon_client.post(
69
+ "/api/users/auth/token",
70
+ json={"email": "inactive@example.com", "password": "SecurePass1!"},
71
+ )
72
+ assert resp.status_code == 401
73
+
74
+ @pytest.mark.anyio
75
+ async def test_valid_credentials_returns_token_pair(self, anon_client, users_db):
76
+ await _seed_user(users_db)
77
+ resp = await anon_client.post(
78
+ "/api/users/auth/token",
79
+ json={"email": "api@example.com", "password": "SecurePass1!"},
80
+ )
81
+ assert resp.status_code == 200
82
+ body = resp.json()
83
+ assert body["token_type"] == "bearer"
84
+ assert body["access_token"]
85
+ assert body["refresh_token"]
86
+ assert body["expires_in"] > 0
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # POST /api/users/auth/token/refresh
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class TestTokenRefresh:
95
+ @pytest.mark.anyio
96
+ async def test_invalid_uuid_returns_401(self, anon_client):
97
+ resp = await anon_client.post(
98
+ "/api/users/auth/token/refresh",
99
+ json={"refresh_token": "not-a-uuid"},
100
+ )
101
+ assert resp.status_code == 401
102
+ assert resp.json()["detail"] == "Invalid refresh token"
103
+
104
+ @pytest.mark.anyio
105
+ async def test_nonexistent_token_returns_401(self, anon_client):
106
+ resp = await anon_client.post(
107
+ "/api/users/auth/token/refresh",
108
+ json={"refresh_token": str(uuid.uuid4())},
109
+ )
110
+ assert resp.status_code == 401
111
+ assert resp.json()["detail"] == "Invalid or expired refresh token"
112
+
113
+ @pytest.mark.anyio
114
+ async def test_valid_refresh_rotates_tokens(self, anon_client, users_db):
115
+ """Login, then refresh — old refresh revoked, new pair returned."""
116
+ await _seed_user(users_db)
117
+ login = await anon_client.post(
118
+ "/api/users/auth/token",
119
+ json={"email": "api@example.com", "password": "SecurePass1!"},
120
+ )
121
+ assert login.status_code == 200
122
+ old_refresh = login.json()["refresh_token"]
123
+
124
+ refresh_resp = await anon_client.post(
125
+ "/api/users/auth/token/refresh",
126
+ json={"refresh_token": old_refresh},
127
+ )
128
+ assert refresh_resp.status_code == 200
129
+ new_body = refresh_resp.json()
130
+ assert new_body["access_token"]
131
+ assert new_body["refresh_token"] != old_refresh
132
+
133
+ # Old refresh token should now be revoked
134
+ reuse = await anon_client.post(
135
+ "/api/users/auth/token/refresh",
136
+ json={"refresh_token": old_refresh},
137
+ )
138
+ assert reuse.status_code == 401
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # DELETE /api/users/auth/token — revoke
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ class TestTokenRevoke:
147
+ @pytest.mark.anyio
148
+ async def test_invalid_format_returns_400(self, anon_client):
149
+ resp = await anon_client.request(
150
+ "DELETE",
151
+ "/api/users/auth/token",
152
+ json={"refresh_token": "garbage"},
153
+ )
154
+ assert resp.status_code == 400
155
+ assert resp.json()["detail"] == "Invalid token format"
156
+
157
+ @pytest.mark.anyio
158
+ async def test_nonexistent_token_returns_ok(self, anon_client):
159
+ """Revoking a non-existent token is idempotent — still returns ok."""
160
+ resp = await anon_client.request(
161
+ "DELETE",
162
+ "/api/users/auth/token",
163
+ json={"refresh_token": str(uuid.uuid4())},
164
+ )
165
+ assert resp.status_code == 200
166
+ assert resp.json()["status"] == "ok"
167
+
168
+ @pytest.mark.anyio
169
+ async def test_revoke_makes_refresh_unusable(self, anon_client, users_db):
170
+ """After revoking, the refresh token can no longer be used."""
171
+ await _seed_user(users_db)
172
+ login = await anon_client.post(
173
+ "/api/users/auth/token",
174
+ json={"email": "api@example.com", "password": "SecurePass1!"},
175
+ )
176
+ rt = login.json()["refresh_token"]
177
+
178
+ # Revoke
179
+ revoke_resp = await anon_client.request(
180
+ "DELETE",
181
+ "/api/users/auth/token",
182
+ json={"refresh_token": rt},
183
+ )
184
+ assert revoke_resp.status_code == 200
185
+
186
+ # Attempt refresh — should fail
187
+ refresh_resp = await anon_client.post(
188
+ "/api/users/auth/token/refresh",
189
+ json={"refresh_token": rt},
190
+ )
191
+ assert refresh_resp.status_code == 401
@@ -0,0 +1,46 @@
1
+ """Tests for UsersAuthProvider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from auth.contracts.provider import AuthProvider
6
+ from users.provider import UsersAuthProvider
7
+
8
+
9
+ def test_users_provider_satisfies_protocol():
10
+ provider = UsersAuthProvider()
11
+ assert isinstance(provider, AuthProvider)
12
+
13
+
14
+ def test_login_url():
15
+ provider = UsersAuthProvider()
16
+ assert provider.get_login_url(None) == "/users/login"
17
+
18
+
19
+ def test_logout_url():
20
+ provider = UsersAuthProvider()
21
+ assert provider.get_logout_url(None) == "/users/logout"
22
+
23
+
24
+ def test_public_paths():
25
+ provider = UsersAuthProvider()
26
+ prefixes, _exact = provider.get_public_paths()
27
+ assert "/users/login" in prefixes
28
+ assert "/api/users/auth/" in prefixes
29
+
30
+
31
+ def test_is_bearer_request_true():
32
+ from unittest.mock import MagicMock
33
+
34
+ request = MagicMock()
35
+ request.headers = {"authorization": "Bearer abc123"}
36
+ provider = UsersAuthProvider()
37
+ assert provider.is_bearer_request(request) is True
38
+
39
+
40
+ def test_is_bearer_request_false():
41
+ from unittest.mock import MagicMock
42
+
43
+ request = MagicMock()
44
+ request.headers = {}
45
+ provider = UsersAuthProvider()
46
+ assert provider.is_bearer_request(request) is False
@@ -117,12 +117,22 @@ async def login(
117
117
  # ── Mount fastapi-users stock routers ────────────────────────────────────────
118
118
 
119
119
  # The stock auth router (login + logout) is mounted at /auth-inner so its
120
- # logout and other endpoints remain accessible. Our wrapper at /auth/login
121
- # shadows the stock login endpoint. Logout is exposed via /auth-inner/logout.
120
+ # endpoints remain accessible. Our wrappers at /auth/login and /auth/logout
121
+ # shadow the stock endpoints to also manage the session cookie.
122
122
  auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
123
123
  router.include_router(auth_inner, prefix="/auth-inner")
124
124
 
125
125
 
126
+ @router.post("/auth/logout", status_code=204)
127
+ async def api_logout(request: Request):
128
+ """API logout — clears both the access-token cookie and the session."""
129
+ request.session.clear()
130
+ cookie_name = request.app.state.users.settings.cookie_name
131
+ response = Response(status_code=204)
132
+ response.delete_cookie(cookie_name, path="/")
133
+ return response
134
+
135
+
126
136
  # ── Accept-invite (verify + set password + login, one shot) ─────────────────
127
137
 
128
138
 
@@ -0,0 +1,159 @@
1
+ """Bearer token endpoints for mobile/API clients.
2
+
3
+ Provides email+password → access_token + refresh_token, refresh, and revoke
4
+ flows for clients that cannot use browser cookies (mobile apps, CLI tools,
5
+ third-party API consumers).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid as uuid_mod
11
+ from datetime import UTC, datetime, timedelta
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException, Request
14
+ from simple_module_db.deps import get_db
15
+ from sqlalchemy import select
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+ from sqlmodel import SQLModel
18
+
19
+ from users.models import User
20
+ from users.models.refresh_token import RefreshToken
21
+
22
+ # Pre-computed bcrypt hash used when a login attempt targets a non-existent
23
+ # user. Running verify against this takes the same time as a real check,
24
+ # preventing timing-based email enumeration.
25
+ _DUMMY_HASH = "$2b$12$LJ3m4ys3Lg/PFgWCZxEzR.ZVxFMz3yeqHEhSYmiJ9gJOPG7W3Cq2G"
26
+
27
+ router = APIRouter(prefix="/auth", tags=["users-token"])
28
+
29
+
30
+ class TokenRequest(SQLModel):
31
+ """Email + password login for bearer-token auth."""
32
+
33
+ email: str
34
+ password: str
35
+
36
+
37
+ class TokenResponse(SQLModel):
38
+ """Access + refresh token pair returned on successful auth."""
39
+
40
+ access_token: str
41
+ refresh_token: str
42
+ token_type: str = "bearer"
43
+ expires_in: int
44
+
45
+
46
+ class RefreshRequest(SQLModel):
47
+ """Body for refresh and revoke endpoints."""
48
+
49
+ refresh_token: str
50
+
51
+
52
+ @router.post("/token", response_model=TokenResponse)
53
+ async def token_login(
54
+ body: TokenRequest,
55
+ request: Request,
56
+ db: AsyncSession = Depends(get_db),
57
+ ):
58
+ """Exchange email + password for an access/refresh token pair."""
59
+ from fastapi_users.password import PasswordHelper
60
+
61
+ helper = PasswordHelper()
62
+
63
+ stmt = select(User).where(User.email == body.email)
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:
66
+ # Constant-time: run bcrypt on a dummy hash to prevent timing-based
67
+ # email enumeration (existing user + wrong password takes ~50ms for
68
+ # bcrypt; missing user would be instant without this).
69
+ helper.verify_and_update(body.password, _DUMMY_HASH)
70
+ raise HTTPException(status_code=401, detail="Invalid credentials")
71
+
72
+ verified, _ = helper.verify_and_update(body.password, user.hashed_password)
73
+ if not verified:
74
+ raise HTTPException(status_code=401, detail="Invalid credentials")
75
+
76
+ settings = request.app.state.users.settings
77
+ return await _create_token_pair(db, user.id, settings)
78
+
79
+
80
+ @router.post("/token/refresh", response_model=TokenResponse)
81
+ async def token_refresh(
82
+ body: RefreshRequest,
83
+ request: Request,
84
+ db: AsyncSession = Depends(get_db),
85
+ ):
86
+ """Rotate a refresh token into a new access/refresh pair."""
87
+ try:
88
+ token_uuid = uuid_mod.UUID(body.refresh_token)
89
+ except (ValueError, TypeError):
90
+ raise HTTPException(status_code=401, detail="Invalid refresh token") from None
91
+
92
+ now = datetime.now(UTC)
93
+ stmt = select(RefreshToken).where(
94
+ RefreshToken.token == token_uuid,
95
+ RefreshToken.revoked_at.is_(None), # type: ignore[union-attr]
96
+ RefreshToken.expires_at > now,
97
+ )
98
+ rt = (await db.execute(stmt)).scalar_one_or_none()
99
+ if rt is None:
100
+ raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
101
+
102
+ rt.revoked_at = now
103
+ await db.flush()
104
+
105
+ settings = request.app.state.users.settings
106
+ return await _create_token_pair(db, rt.user_id, settings)
107
+
108
+
109
+ @router.delete("/token")
110
+ async def token_revoke(
111
+ body: RefreshRequest,
112
+ db: AsyncSession = Depends(get_db),
113
+ ):
114
+ """Revoke a refresh token (idempotent)."""
115
+ try:
116
+ token_uuid = uuid_mod.UUID(body.refresh_token)
117
+ except (ValueError, TypeError):
118
+ raise HTTPException(status_code=400, detail="Invalid token format") from None
119
+
120
+ stmt = select(RefreshToken).where(RefreshToken.token == token_uuid)
121
+ rt = (await db.execute(stmt)).scalar_one_or_none()
122
+ if rt and rt.revoked_at is None:
123
+ rt.revoked_at = datetime.now(UTC)
124
+ await db.flush()
125
+ return {"status": "ok"}
126
+
127
+
128
+ async def _create_token_pair(
129
+ db: AsyncSession,
130
+ user_id: uuid_mod.UUID,
131
+ settings,
132
+ ) -> TokenResponse:
133
+ """Mint a new access token + refresh token pair and persist both."""
134
+ from users.models import UserAccessToken
135
+
136
+ now = datetime.now(UTC)
137
+
138
+ access_token = UserAccessToken(
139
+ token=str(uuid_mod.uuid4()),
140
+ user_id=user_id,
141
+ created_at=now,
142
+ )
143
+ db.add(access_token)
144
+
145
+ refresh = RefreshToken(
146
+ token=uuid_mod.uuid4(),
147
+ user_id=user_id,
148
+ created_at=now,
149
+ expires_at=now + timedelta(seconds=settings.refresh_token_lifetime_seconds),
150
+ )
151
+ db.add(refresh)
152
+ await db.flush()
153
+
154
+ return TokenResponse(
155
+ access_token=access_token.token,
156
+ refresh_token=str(refresh.token),
157
+ token_type="bearer",
158
+ expires_in=settings.bearer_token_lifetime_seconds,
159
+ )
@@ -0,0 +1,10 @@
1
+ """Backwards-compatibility re-export.
2
+
3
+ The canonical AuthMiddleware now lives in ``auth.middleware``. This shim
4
+ exists only to avoid breaking imports in downstream apps that referenced
5
+ ``users.middleware.AuthMiddleware`` directly.
6
+ """
7
+
8
+ from auth.middleware import AuthMiddleware
9
+
10
+ __all__ = ["AuthMiddleware"]
@@ -10,6 +10,7 @@ from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDataba
10
10
  from users.models._base import Base
11
11
  from users.models.access_token import UserAccessToken
12
12
  from users.models.oauth_account import OAuthAccount
13
+ from users.models.refresh_token import RefreshToken
13
14
  from users.models.role import Role
14
15
  from users.models.user import User
15
16
  from users.models.user_role import UserRole
@@ -17,6 +18,7 @@ from users.models.user_role import UserRole
17
18
  __all__ = [
18
19
  "Base",
19
20
  "OAuthAccount",
21
+ "RefreshToken",
20
22
  "Role",
21
23
  "SQLAlchemyAccessTokenDatabase",
22
24
  "SQLAlchemyUserDatabase",
@@ -0,0 +1,22 @@
1
+ """Refresh token for mobile/API bearer auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid as uuid_mod
6
+ from datetime import UTC, datetime
7
+
8
+ from sqlmodel import Field
9
+
10
+ from users.models._base import Base
11
+
12
+
13
+ class RefreshToken(Base, table=True): # ty: ignore[unsupported-base]
14
+ """Opaque refresh token exchanged for a new access + refresh pair."""
15
+
16
+ __tablename__ = "users_refresh_token"
17
+
18
+ token: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True)
19
+ user_id: uuid_mod.UUID = Field(foreign_key="users_user.id", index=True)
20
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
21
+ expires_at: datetime
22
+ revoked_at: datetime | None = None
@@ -39,12 +39,11 @@ class UsersModule(ModuleBase):
39
39
  view_prefix="/users",
40
40
  depends_on=[_MODULE_DEPENDENCY_AUTH],
41
41
  )
42
+ _is_auth_provider = True
42
43
 
43
44
  def register_settings(self, app: FastAPI) -> None:
44
45
  import importlib
45
46
 
46
- from auth.contracts.schemas import UserContext
47
-
48
47
  from users.settings import UsersSettings
49
48
  from users.state import UsersState
50
49
 
@@ -58,15 +57,9 @@ class UsersModule(ModuleBase):
58
57
 
59
58
  register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s))
60
59
 
61
- def serialize_principal(user: UserContext) -> dict:
62
- return {
63
- "id": user.id,
64
- "name": user.name,
65
- "email": user.email,
66
- "roles": user.roles,
67
- }
60
+ from users.provider import UsersAuthProvider
68
61
 
69
- app.state.principal_serializer = serialize_principal
62
+ app.state.auth.auth_provider = UsersAuthProvider()
70
63
 
71
64
  def register_permissions(self, registry: PermissionRegistry) -> None:
72
65
  registry.add_group(
@@ -113,6 +106,7 @@ class UsersModule(ModuleBase):
113
106
  from users.admin.api import admin_router
114
107
  from users.admin.views import router as admin_views
115
108
  from users.auth_local import api as auth_local_api
109
+ from users.auth_local.token_api import router as token_router
116
110
  from users.auth_local.views import router as auth_views
117
111
  from users.contracts.schemas import UserCreate, UserRead
118
112
  from users.deps import fastapi_users
@@ -127,6 +121,7 @@ class UsersModule(ModuleBase):
127
121
  settings = UsersSettings()
128
122
 
129
123
  api_router.include_router(auth_local_api.router)
124
+ api_router.include_router(token_router)
130
125
  api_router.include_router(admin_router)
131
126
  # Throughput-wrap the stock fastapi-users routers; ``require_signup_enabled``
132
127
  # gates /register at request time so ``allow_signup`` is hot-reloadable.
@@ -156,11 +151,6 @@ class UsersModule(ModuleBase):
156
151
  view_router.include_router(auth_views)
157
152
  view_router.include_router(admin_views)
158
153
 
159
- def register_middleware(self, app: FastAPI) -> None:
160
- from users.middleware import AuthMiddleware
161
-
162
- app.add_middleware(AuthMiddleware)
163
-
164
154
  async def on_startup(self, app: FastAPI) -> None:
165
155
  """Build the mailer, rate limiter, and apply production cookie params."""
166
156
  import asyncio