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.
Files changed (105) hide show
  1. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/PKG-INFO +37 -6
  2. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/README.md +31 -0
  3. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/pyproject.toml +6 -6
  4. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/_middleware_support.py +4 -1
  5. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_oauth.py +64 -24
  6. simple_module_users-0.0.18/tests/test_oauth_routes.py +105 -0
  7. simple_module_users-0.0.18/tests/test_token_api.py +191 -0
  8. simple_module_users-0.0.18/tests/test_users_provider.py +46 -0
  9. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/api.py +12 -2
  10. simple_module_users-0.0.18/users/auth_local/token_api.py +159 -0
  11. simple_module_users-0.0.18/users/middleware.py +10 -0
  12. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/__init__.py +2 -0
  13. simple_module_users-0.0.18/users/models/refresh_token.py +22 -0
  14. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/module.py +34 -26
  15. simple_module_users-0.0.18/users/oauth/__init__.py +5 -0
  16. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/oauth/api.py +43 -56
  17. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/oauth/providers.py +26 -22
  18. simple_module_users-0.0.18/users/provider.py +130 -0
  19. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/settings.py +31 -14
  20. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/state.py +2 -0
  21. simple_module_users-0.0.16/users/middleware.py +0 -168
  22. simple_module_users-0.0.16/users/oauth/__init__.py +0 -5
  23. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/.gitignore +0 -0
  24. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/LICENSE +0 -0
  25. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/package.json +0 -0
  26. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/.gitkeep +0 -0
  27. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/conftest.py +0 -0
  28. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_access_token_model.py +0 -0
  29. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_api_admin.py +0 -0
  30. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_api_admin_filters.py +0 -0
  31. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_api_auth.py +0 -0
  32. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_backend.py +0 -0
  33. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_bootstrap.py +0 -0
  34. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_bootstrap_resolution.py +0 -0
  35. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_cli.py +0 -0
  36. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_constants.py +0 -0
  37. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_db_adapter.py +0 -0
  38. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_invite_flow.py +0 -0
  39. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_invite_reuse.py +0 -0
  40. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_mailer.py +0 -0
  41. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_negative_authz.py +0 -0
  42. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_rate_limit.py +0 -0
  43. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_role_model.py +0 -0
  44. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_service_admin.py +0 -0
  45. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_settings.py +0 -0
  46. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_manager.py +0 -0
  47. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_model.py +0 -0
  48. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_role_model.py +0 -0
  49. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_user_service.py +0 -0
  50. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_deps.py +0 -0
  51. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_middleware.py +0 -0
  52. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_middleware_public_paths.py +0 -0
  53. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_users_middleware_resolvers.py +0 -0
  54. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_views.py +0 -0
  55. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tests/test_views_admin.py +0 -0
  56. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/tsconfig.json +0 -0
  57. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/__init__.py +0 -0
  58. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/__init__.py +0 -0
  59. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/api.py +0 -0
  60. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/components/IndexFilters.tsx +0 -0
  61. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/components/RolesTab.tsx +0 -0
  62. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/components/UserRow.tsx +0 -0
  63. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/service.py +0 -0
  64. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/admin/views.py +0 -0
  65. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/__init__.py +0 -0
  66. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/rate_limit.py +0 -0
  67. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/auth_local/views.py +0 -0
  68. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/backend.py +0 -0
  69. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/bootstrap.py +0 -0
  70. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/cli.py +0 -0
  71. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/constants.py +0 -0
  72. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/contracts/__init__.py +0 -0
  73. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/contracts/events.py +0 -0
  74. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/contracts/schemas.py +0 -0
  75. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/db_adapter.py +0 -0
  76. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/deps.py +0 -0
  77. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/exceptions.py +0 -0
  78. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/__init__.py +0 -0
  79. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/console.py +0 -0
  80. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/smtp.py +0 -0
  81. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/.gitkeep +0 -0
  82. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/invite.txt +0 -0
  83. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/reset_password.txt +0 -0
  84. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/mailer/templates/verify_email.txt +0 -0
  85. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/manager.py +0 -0
  86. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/_base.py +0 -0
  87. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/access_token.py +0 -0
  88. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/oauth_account.py +0 -0
  89. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/role.py +0 -0
  90. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/user.py +0 -0
  91. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/models/user_role.py +0 -0
  92. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/.gitkeep +0 -0
  93. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/AcceptInvite.tsx +0 -0
  94. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/ForgotPassword.tsx +0 -0
  95. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Login.tsx +0 -0
  96. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Profile.tsx +0 -0
  97. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Register.tsx +0 -0
  98. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/ResetPassword.tsx +0 -0
  99. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/Edit.tsx +0 -0
  100. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/Index.tsx +0 -0
  101. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/Invite.tsx +0 -0
  102. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  103. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/pages/VerifyEmail.tsx +0 -0
  104. {simple_module_users-0.0.16 → simple_module_users-0.0.18}/users/py.typed +0 -0
  105. {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.16
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.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.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.16"
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.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.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 + integration tests for the OAuth/OIDC plumbing.
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
- - ``enabled_provider_names`` correctly reflects settings.
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 build_clients, enabled_provider_names
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 test_enabled_provider_names_empty_by_default():
36
- assert enabled_provider_names(UsersSettings()) == []
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 test_enabled_provider_names_lists_configured_providers():
57
+ def test_build_clients_includes_microsoft():
40
58
  s = UsersSettings(
41
- oauth_google_client_id="g-id",
42
- oauth_google_client_secret="g-secret",
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
- names = [p["name"] for p in enabled_provider_names(s)]
47
- assert names == ["google", "github"]
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 test_enabled_provider_names_skips_provider_missing_secret():
51
- s = UsersSettings(oauth_google_client_id="g-id") # no secret
52
- assert enabled_provider_names(s) == []
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
- def test_enabled_provider_names_oidc_requires_discovery_url():
73
+ @pytest.mark.anyio
74
+ async def test_microsoft_authorize_url_carries_tenant():
56
75
  s = UsersSettings(
57
- oauth_oidc_client_id="x",
58
- oauth_oidc_client_secret="y",
59
- # discovery_url unset → not registered
76
+ oauth_microsoft_client_id="ms-id",
77
+ oauth_microsoft_client_secret="ms-secret",
78
+ oauth_microsoft_tenant="my-tenant-guid",
60
79
  )
61
- assert enabled_provider_names(s) == []
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
- # build_clients (no-network providers only)
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
- # 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