simple-module-users 0.0.16__tar.gz → 0.0.18__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.16 → simple_module_users-0.0.18}/PKG-INFO +37 -6
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/README.md +31 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/pyproject.toml +6 -6
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/_middleware_support.py +4 -1
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_oauth.py +64 -24
- simple_module_users-0.0.18/tests/test_oauth_routes.py +105 -0
- simple_module_users-0.0.18/tests/test_token_api.py +191 -0
- simple_module_users-0.0.18/tests/test_users_provider.py +46 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/api.py +12 -2
- simple_module_users-0.0.18/users/auth_local/token_api.py +159 -0
- simple_module_users-0.0.18/users/middleware.py +10 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/__init__.py +2 -0
- simple_module_users-0.0.18/users/models/refresh_token.py +22 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/module.py +34 -26
- simple_module_users-0.0.18/users/oauth/__init__.py +5 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/oauth/api.py +43 -56
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/oauth/providers.py +26 -22
- simple_module_users-0.0.18/users/provider.py +130 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/settings.py +31 -14
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/state.py +2 -0
- simple_module_users-0.0.16/users/middleware.py +0 -168
- simple_module_users-0.0.16/users/oauth/__init__.py +0 -5
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/.gitignore +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/LICENSE +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/package.json +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/conftest.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_views.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tsconfig.json +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/api.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/service.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/views.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/backend.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/cli.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/constants.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/deps.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/exceptions.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/manager.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/_base.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/role.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/user.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/py.typed +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/roles_cache.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.18
|
|
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.18
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.18
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.18
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.18
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.18
|
|
32
32
|
Requires-Dist: typer>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -84,6 +84,37 @@ async def profile(user: CurrentUser):
|
|
|
84
84
|
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
|
|
85
85
|
- `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
|
|
86
86
|
|
|
87
|
+
## Social sign-in (Google, GitHub, Microsoft, OIDC)
|
|
88
|
+
|
|
89
|
+
OAuth providers are configured in the admin UI at **/settings/modules → Users**
|
|
90
|
+
(no environment variables). Each provider activates once its client id **and**
|
|
91
|
+
secret are set; the secret is masked in the UI. Changes apply live — no restart.
|
|
92
|
+
|
|
93
|
+
**Microsoft (Entra ID).** Register an app in the Entra admin center and set the
|
|
94
|
+
redirect URI to `<base-url>/api/users/auth/microsoft/callback`. Configure under
|
|
95
|
+
the **Microsoft OAuth** group:
|
|
96
|
+
|
|
97
|
+
- `oauth_microsoft_client_id`, `oauth_microsoft_client_secret`
|
|
98
|
+
- `oauth_microsoft_tenant` — `common` (any work/school or personal account,
|
|
99
|
+
the default), `organizations` (work/school only), or your tenant GUID to
|
|
100
|
+
restrict sign-in to one tenant.
|
|
101
|
+
|
|
102
|
+
Each provider's callback URL is `<base-url>/api/users/auth/<provider>/callback`
|
|
103
|
+
(`google`, `github`, `microsoft`, `oidc`).
|
|
104
|
+
|
|
105
|
+
> **Note:** for Microsoft *guest/external* accounts the identity email comes
|
|
106
|
+
> from the Graph `userPrincipalName`, which may not be a plain email
|
|
107
|
+
> (e.g. `user_ext.com#EXT#@tenant.onmicrosoft.com`). For tenant members it is
|
|
108
|
+
> the user's email.
|
|
109
|
+
|
|
110
|
+
### Migrating from `SM_USERS_OAUTH_*` env vars
|
|
111
|
+
|
|
112
|
+
Earlier versions read provider credentials from `SM_USERS_OAUTH_*` environment
|
|
113
|
+
variables. These are no longer read at runtime. Migrate existing values into the
|
|
114
|
+
settings store once with:
|
|
115
|
+
|
|
116
|
+
uv run smpy settings import-from-env
|
|
117
|
+
|
|
87
118
|
## License
|
|
88
119
|
|
|
89
120
|
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -50,6 +50,37 @@ async def profile(user: CurrentUser):
|
|
|
50
50
|
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
|
|
51
51
|
- `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
|
|
52
52
|
|
|
53
|
+
## Social sign-in (Google, GitHub, Microsoft, OIDC)
|
|
54
|
+
|
|
55
|
+
OAuth providers are configured in the admin UI at **/settings/modules → Users**
|
|
56
|
+
(no environment variables). Each provider activates once its client id **and**
|
|
57
|
+
secret are set; the secret is masked in the UI. Changes apply live — no restart.
|
|
58
|
+
|
|
59
|
+
**Microsoft (Entra ID).** Register an app in the Entra admin center and set the
|
|
60
|
+
redirect URI to `<base-url>/api/users/auth/microsoft/callback`. Configure under
|
|
61
|
+
the **Microsoft OAuth** group:
|
|
62
|
+
|
|
63
|
+
- `oauth_microsoft_client_id`, `oauth_microsoft_client_secret`
|
|
64
|
+
- `oauth_microsoft_tenant` — `common` (any work/school or personal account,
|
|
65
|
+
the default), `organizations` (work/school only), or your tenant GUID to
|
|
66
|
+
restrict sign-in to one tenant.
|
|
67
|
+
|
|
68
|
+
Each provider's callback URL is `<base-url>/api/users/auth/<provider>/callback`
|
|
69
|
+
(`google`, `github`, `microsoft`, `oidc`).
|
|
70
|
+
|
|
71
|
+
> **Note:** for Microsoft *guest/external* accounts the identity email comes
|
|
72
|
+
> from the Graph `userPrincipalName`, which may not be a plain email
|
|
73
|
+
> (e.g. `user_ext.com#EXT#@tenant.onmicrosoft.com`). For tenant members it is
|
|
74
|
+
> the user's email.
|
|
75
|
+
|
|
76
|
+
### Migrating from `SM_USERS_OAUTH_*` env vars
|
|
77
|
+
|
|
78
|
+
Earlier versions read provider credentials from `SM_USERS_OAUTH_*` environment
|
|
79
|
+
variables. These are no longer read at runtime. Migrate existing values into the
|
|
80
|
+
settings store once with:
|
|
81
|
+
|
|
82
|
+
uv run smpy settings import-from-env
|
|
83
|
+
|
|
53
84
|
## License
|
|
54
85
|
|
|
55
86
|
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.18"
|
|
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.18",
|
|
25
|
+
"simple_module_db==0.0.18",
|
|
26
|
+
"simple_module_hosting==0.0.18",
|
|
27
|
+
"simple_module_settings==0.0.18",
|
|
28
|
+
"simple_module_auth==0.0.18",
|
|
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
|
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
"""Unit
|
|
1
|
+
"""Unit tests for the OAuth/OIDC plumbing.
|
|
2
2
|
|
|
3
3
|
Provider client construction and the /authorize+/callback ASGI flow are not
|
|
4
4
|
covered here because both depend on real httpx-oauth clients that hit the
|
|
5
5
|
network (token exchange, profile fetch). Those are best validated in a manual
|
|
6
6
|
QA pass against a dev IdP. What this file *does* cover:
|
|
7
7
|
|
|
8
|
-
- ``
|
|
9
|
-
- ``build_clients`` instantiates the Google + GitHub clients when configured.
|
|
8
|
+
- ``build_clients`` / ``build_client_map`` instantiate clients when configured.
|
|
10
9
|
- ``OAuthAccount`` persists and FK-cascades on user delete.
|
|
11
10
|
- ``UserManager.oauth_callback`` (the find-or-create core fastapi-users helper
|
|
12
11
|
the route delegates to) creates a fresh user + linked OAuthAccount, and
|
|
13
12
|
associates by email when the user already exists.
|
|
13
|
+
|
|
14
|
+
HTTP dispatcher and live-reload (``SettingsReloaded``) integration tests live
|
|
15
|
+
in ``test_oauth_routes.py``.
|
|
14
16
|
"""
|
|
15
17
|
|
|
16
18
|
from __future__ import annotations
|
|
@@ -21,7 +23,7 @@ import pytest
|
|
|
21
23
|
from fastapi_users.password import PasswordHelper
|
|
22
24
|
from sqlalchemy import select
|
|
23
25
|
from users.models import OAuthAccount, User
|
|
24
|
-
from users.oauth import
|
|
26
|
+
from users.oauth import build_client_map, build_clients
|
|
25
27
|
from users.settings import UsersSettings
|
|
26
28
|
|
|
27
29
|
_pw = PasswordHelper()
|
|
@@ -32,38 +34,64 @@ _pw = PasswordHelper()
|
|
|
32
34
|
# ---------------------------------------------------------------------------
|
|
33
35
|
|
|
34
36
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
+
def test_microsoft_settings_defaults():
|
|
38
|
+
s = UsersSettings()
|
|
39
|
+
assert s.oauth_microsoft_client_id == ""
|
|
40
|
+
assert s.oauth_microsoft_client_secret == ""
|
|
41
|
+
assert s.oauth_microsoft_tenant == "common"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_oauth_fields_carry_group_metadata_for_settings_ui():
|
|
45
|
+
fields = UsersSettings.model_fields
|
|
46
|
+
assert fields["oauth_google_client_id"].json_schema_extra == {"group": "Google OAuth"}
|
|
47
|
+
assert fields["oauth_github_client_id"].json_schema_extra == {"group": "GitHub OAuth"}
|
|
48
|
+
assert fields["oauth_oidc_discovery_url"].json_schema_extra == {"group": "OIDC"}
|
|
49
|
+
assert fields["oauth_microsoft_client_secret"].json_schema_extra == {"group": "Microsoft OAuth"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# build_clients (no-network providers only)
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
37
55
|
|
|
38
56
|
|
|
39
|
-
def
|
|
57
|
+
def test_build_clients_includes_microsoft():
|
|
40
58
|
s = UsersSettings(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
oauth_github_client_id="gh-id",
|
|
44
|
-
oauth_github_client_secret="gh-secret",
|
|
59
|
+
oauth_microsoft_client_id="ms-id",
|
|
60
|
+
oauth_microsoft_client_secret="ms-secret",
|
|
45
61
|
)
|
|
46
|
-
|
|
47
|
-
assert
|
|
62
|
+
providers = build_clients(s)
|
|
63
|
+
assert [p.name for p in providers] == ["microsoft"]
|
|
64
|
+
assert providers[0].display_name == "Microsoft"
|
|
65
|
+
assert providers[0].client.client_id == "ms-id"
|
|
48
66
|
|
|
49
67
|
|
|
50
|
-
def
|
|
51
|
-
s = UsersSettings(
|
|
52
|
-
assert
|
|
68
|
+
def test_build_clients_skips_microsoft_without_secret():
|
|
69
|
+
s = UsersSettings(oauth_microsoft_client_id="ms-id") # no secret
|
|
70
|
+
assert [p.name for p in build_clients(s)] == []
|
|
53
71
|
|
|
54
72
|
|
|
55
|
-
|
|
73
|
+
@pytest.mark.anyio
|
|
74
|
+
async def test_microsoft_authorize_url_carries_tenant():
|
|
56
75
|
s = UsersSettings(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
oauth_microsoft_client_id="ms-id",
|
|
77
|
+
oauth_microsoft_client_secret="ms-secret",
|
|
78
|
+
oauth_microsoft_tenant="my-tenant-guid",
|
|
60
79
|
)
|
|
61
|
-
|
|
80
|
+
client = build_client_map(s)["microsoft"].client
|
|
81
|
+
url = await client.get_authorization_url("http://testserver/cb", "state123")
|
|
82
|
+
assert "my-tenant-guid" in url
|
|
62
83
|
|
|
63
84
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
def test_build_client_map_keys_by_name():
|
|
86
|
+
s = UsersSettings(
|
|
87
|
+
oauth_google_client_id="g-id",
|
|
88
|
+
oauth_google_client_secret="g-secret",
|
|
89
|
+
oauth_microsoft_client_id="ms-id",
|
|
90
|
+
oauth_microsoft_client_secret="ms-secret",
|
|
91
|
+
)
|
|
92
|
+
m = build_client_map(s)
|
|
93
|
+
assert set(m) == {"google", "microsoft"}
|
|
94
|
+
assert m["microsoft"].name == "microsoft"
|
|
67
95
|
|
|
68
96
|
|
|
69
97
|
def test_build_clients_google_and_github():
|
|
@@ -187,3 +215,15 @@ async def test_oauth_callback_links_to_existing_email(users_app, users_db):
|
|
|
187
215
|
assert names == ["github"]
|
|
188
216
|
finally:
|
|
189
217
|
await session.__aexit__(None, None, None)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# UsersState defaults
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_users_state_defaults_empty_oauth_clients():
|
|
226
|
+
from users.state import UsersState
|
|
227
|
+
|
|
228
|
+
state = UsersState(settings=UsersSettings())
|
|
229
|
+
assert state.oauth_clients == {}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""OAuth HTTP dispatcher + live-reload tests.
|
|
2
|
+
|
|
3
|
+
Exercises the running app: the provider-agnostic ``/auth/{provider}/{login,callback}``
|
|
4
|
+
dispatcher (resolution, 404, state CSRF) and the ``SettingsReloaded`` hot-reload of the
|
|
5
|
+
provider cache. Provider construction and the account model are unit-tested in
|
|
6
|
+
``test_oauth.py``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Provider-agnostic dispatcher (request-time client resolution)
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.anyio
|
|
19
|
+
async def test_oauth_login_redirects_for_configured_provider(users_app, anon_client):
|
|
20
|
+
from httpx_oauth.clients.microsoft import MicrosoftGraphOAuth2
|
|
21
|
+
from users.oauth import OAuthProvider
|
|
22
|
+
|
|
23
|
+
users_app.state.users.oauth_clients["microsoft"] = OAuthProvider(
|
|
24
|
+
"microsoft", "Microsoft", MicrosoftGraphOAuth2("ms-id", "ms-secret")
|
|
25
|
+
)
|
|
26
|
+
resp = await anon_client.get("/api/users/auth/microsoft/login", follow_redirects=False)
|
|
27
|
+
assert resp.status_code == 302
|
|
28
|
+
assert "login.microsoftonline.com" in resp.headers["location"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.anyio
|
|
32
|
+
async def test_oauth_login_404_for_unknown_provider(anon_client):
|
|
33
|
+
resp = await anon_client.get("/api/users/auth/nope/login", follow_redirects=False)
|
|
34
|
+
assert resp.status_code == 404
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.anyio
|
|
38
|
+
async def test_oauth_callback_rejects_bad_state(users_app, anon_client):
|
|
39
|
+
from httpx_oauth.clients.microsoft import MicrosoftGraphOAuth2
|
|
40
|
+
from users.oauth import OAuthProvider
|
|
41
|
+
|
|
42
|
+
users_app.state.users.oauth_clients["microsoft"] = OAuthProvider(
|
|
43
|
+
"microsoft", "Microsoft", MicrosoftGraphOAuth2("ms-id", "ms-secret")
|
|
44
|
+
)
|
|
45
|
+
resp = await anon_client.get(
|
|
46
|
+
"/api/users/auth/microsoft/callback?code=abc&state=bad", follow_redirects=False
|
|
47
|
+
)
|
|
48
|
+
assert resp.status_code == 400
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Live cache rebuild on SettingsReloaded
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.anyio
|
|
57
|
+
async def test_settings_reload_adds_provider_to_cache(users_app):
|
|
58
|
+
from settings.contracts.events import SettingsReloaded
|
|
59
|
+
|
|
60
|
+
assert users_app.state.users.oauth_clients == {}
|
|
61
|
+
assert users_app.state.users.oauth_providers == []
|
|
62
|
+
|
|
63
|
+
users_app.state.users.settings = users_app.state.users.settings.model_copy(
|
|
64
|
+
update={"oauth_microsoft_client_id": "ms-id", "oauth_microsoft_client_secret": "ms-secret"}
|
|
65
|
+
)
|
|
66
|
+
await users_app.state.sm.event_bus.publish(
|
|
67
|
+
SettingsReloaded(package="users", changed=("oauth_microsoft_client_id",))
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert "microsoft" in users_app.state.users.oauth_clients
|
|
71
|
+
buttons = users_app.state.users.oauth_providers
|
|
72
|
+
assert {"name": "microsoft", "display_name": "Microsoft"} in buttons
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.anyio
|
|
76
|
+
async def test_settings_reload_removes_cleared_provider(users_app):
|
|
77
|
+
from settings.contracts.events import SettingsReloaded
|
|
78
|
+
|
|
79
|
+
users_app.state.users.settings = users_app.state.users.settings.model_copy(
|
|
80
|
+
update={"oauth_microsoft_client_id": "ms-id", "oauth_microsoft_client_secret": "ms-secret"}
|
|
81
|
+
)
|
|
82
|
+
await users_app.state.sm.event_bus.publish(
|
|
83
|
+
SettingsReloaded(package="users", changed=("oauth_microsoft_client_id",))
|
|
84
|
+
)
|
|
85
|
+
assert "microsoft" in users_app.state.users.oauth_clients
|
|
86
|
+
|
|
87
|
+
users_app.state.users.settings = users_app.state.users.settings.model_copy(
|
|
88
|
+
update={"oauth_microsoft_client_id": "", "oauth_microsoft_client_secret": ""}
|
|
89
|
+
)
|
|
90
|
+
await users_app.state.sm.event_bus.publish(
|
|
91
|
+
SettingsReloaded(package="users", changed=("oauth_microsoft_client_id",))
|
|
92
|
+
)
|
|
93
|
+
assert "microsoft" not in users_app.state.users.oauth_clients
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.anyio
|
|
97
|
+
async def test_settings_reload_ignores_other_packages(users_app):
|
|
98
|
+
from settings.contracts.events import SettingsReloaded
|
|
99
|
+
|
|
100
|
+
sentinel = object()
|
|
101
|
+
users_app.state.users.oauth_clients["microsoft"] = sentinel
|
|
102
|
+
await users_app.state.sm.event_bus.publish(
|
|
103
|
+
SettingsReloaded(package="background_tasks", changed=("broker_url",))
|
|
104
|
+
)
|
|
105
|
+
assert users_app.state.users.oauth_clients["microsoft"] is sentinel
|
|
@@ -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
|
-
#
|
|
121
|
-
#
|
|
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
|
|