simple-module-users 0.0.17__tar.gz → 0.0.19__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.17 → simple_module_users-0.0.19}/PKG-INFO +40 -9
  2. simple_module_users-0.0.19/README.md +86 -0
  3. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/pyproject.toml +6 -6
  4. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_oauth.py +64 -24
  5. simple_module_users-0.0.19/tests/test_oauth_routes.py +105 -0
  6. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/module.py +29 -11
  7. simple_module_users-0.0.19/users/oauth/__init__.py +5 -0
  8. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/oauth/api.py +43 -56
  9. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/oauth/providers.py +26 -22
  10. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/settings.py +27 -14
  11. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/state.py +2 -0
  12. simple_module_users-0.0.17/README.md +0 -55
  13. simple_module_users-0.0.17/users/oauth/__init__.py +0 -5
  14. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/.gitignore +0 -0
  15. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/LICENSE +0 -0
  16. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/package.json +0 -0
  17. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/.gitkeep +0 -0
  18. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/_middleware_support.py +0 -0
  19. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/conftest.py +0 -0
  20. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_access_token_model.py +0 -0
  21. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_api_admin.py +0 -0
  22. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_api_admin_filters.py +0 -0
  23. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_api_auth.py +0 -0
  24. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_backend.py +0 -0
  25. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_bootstrap.py +0 -0
  26. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_bootstrap_resolution.py +0 -0
  27. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_cli.py +0 -0
  28. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_constants.py +0 -0
  29. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_db_adapter.py +0 -0
  30. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_invite_flow.py +0 -0
  31. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_invite_reuse.py +0 -0
  32. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_mailer.py +0 -0
  33. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_negative_authz.py +0 -0
  34. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_rate_limit.py +0 -0
  35. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_role_model.py +0 -0
  36. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_service_admin.py +0 -0
  37. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_settings.py +0 -0
  38. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_token_api.py +0 -0
  39. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_manager.py +0 -0
  40. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_model.py +0 -0
  41. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_role_model.py +0 -0
  42. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_service.py +0 -0
  43. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_deps.py +0 -0
  44. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_middleware.py +0 -0
  45. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_middleware_public_paths.py +0 -0
  46. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_middleware_resolvers.py +0 -0
  47. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_provider.py +0 -0
  48. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_views.py +0 -0
  49. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_views_admin.py +0 -0
  50. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tsconfig.json +0 -0
  51. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/__init__.py +0 -0
  52. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/__init__.py +0 -0
  53. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/api.py +0 -0
  54. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/IndexFilters.tsx +0 -0
  55. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/RolesTab.tsx +0 -0
  56. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/UserRow.tsx +0 -0
  57. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/service.py +0 -0
  58. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/views.py +0 -0
  59. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/__init__.py +0 -0
  60. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/api.py +0 -0
  61. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/rate_limit.py +0 -0
  62. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/token_api.py +0 -0
  63. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/views.py +0 -0
  64. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/backend.py +0 -0
  65. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/bootstrap.py +0 -0
  66. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/cli.py +0 -0
  67. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/constants.py +0 -0
  68. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/contracts/__init__.py +0 -0
  69. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/contracts/events.py +0 -0
  70. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/contracts/schemas.py +0 -0
  71. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/db_adapter.py +0 -0
  72. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/deps.py +0 -0
  73. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/exceptions.py +0 -0
  74. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/__init__.py +0 -0
  75. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/console.py +0 -0
  76. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/smtp.py +0 -0
  77. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/.gitkeep +0 -0
  78. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/invite.txt +0 -0
  79. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/reset_password.txt +0 -0
  80. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/verify_email.txt +0 -0
  81. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/manager.py +0 -0
  82. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/middleware.py +0 -0
  83. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/__init__.py +0 -0
  84. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/_base.py +0 -0
  85. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/access_token.py +0 -0
  86. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/oauth_account.py +0 -0
  87. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/refresh_token.py +0 -0
  88. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/role.py +0 -0
  89. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/user.py +0 -0
  90. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/user_role.py +0 -0
  91. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/.gitkeep +0 -0
  92. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/AcceptInvite.tsx +0 -0
  93. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/ForgotPassword.tsx +0 -0
  94. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Login.tsx +0 -0
  95. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Profile.tsx +0 -0
  96. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Register.tsx +0 -0
  97. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/ResetPassword.tsx +0 -0
  98. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/Edit.tsx +0 -0
  99. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/Index.tsx +0 -0
  100. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/Invite.tsx +0 -0
  101. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  102. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/VerifyEmail.tsx +0 -0
  103. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/provider.py +0 -0
  104. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/py.typed +0 -0
  105. {simple_module_users-0.0.17 → simple_module_users-0.0.19}/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.17
3
+ Version: 0.0.19
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.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
27
+ Requires-Dist: simple-module-auth==0.0.19
28
+ Requires-Dist: simple-module-core==0.0.19
29
+ Requires-Dist: simple-module-db==0.0.19
30
+ Requires-Dist: simple-module-hosting==0.0.19
31
+ Requires-Dist: simple-module-settings==0.0.19
32
32
  Requires-Dist: typer>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -72,7 +72,7 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me
72
72
  Program:
73
73
 
74
74
  ```python
75
- from users.deps import CurrentUser # type: ignore[import-not-found]
75
+ from auth.deps import CurrentUser # type: ignore[import-not-found]
76
76
 
77
77
  @router.get("/profile")
78
78
  async def profile(user: CurrentUser):
@@ -81,8 +81,39 @@ async def profile(user: CurrentUser):
81
81
 
82
82
  ## Depends on
83
83
 
84
- - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
85
- - `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
84
+ - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
85
+ - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
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
86
117
 
87
118
  ## License
88
119
 
@@ -0,0 +1,86 @@
1
+ # simple_module_users
2
+
3
+ Email+password user management for [simple_module](https://github.com/antosubash/simple_module_python) apps. Replaces Keycloak/Auth0 for the common case: local accounts, admin invites, password reset, optional public signup. Built on `fastapi-users`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install simple_module_users
9
+ ```
10
+
11
+ Pre-wired into any app scaffolded with `smpy new`.
12
+
13
+ ## What it provides
14
+
15
+ - Email + password registration, login, logout, password reset.
16
+ - Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
17
+ - Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
18
+ - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
19
+ - `smpy users create-admin` CLI for ad-hoc admin creation.
20
+ - Inertia pages for login/register/invite-accept/admin-invite.
21
+ - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
22
+
23
+ ## Usage
24
+
25
+ CLI:
26
+
27
+ ```bash
28
+ uv run smpy users create-admin --email admin@example.com --password 'change-me'
29
+ ```
30
+
31
+ Bootstrap-on-boot (`.env`):
32
+
33
+ ```
34
+ SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
35
+ SM_USERS_BOOTSTRAP_PASSWORD=change-me
36
+ ```
37
+
38
+ Program:
39
+
40
+ ```python
41
+ from auth.deps import CurrentUser # type: ignore[import-not-found]
42
+
43
+ @router.get("/profile")
44
+ async def profile(user: CurrentUser):
45
+ return {"email": user.email}
46
+ ```
47
+
48
+ ## Depends on
49
+
50
+ - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
51
+ - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
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
+
84
+ ## License
85
+
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.17"
3
+ version = "0.0.19"
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.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",
24
+ "simple_module_core==0.0.19",
25
+ "simple_module_db==0.0.19",
26
+ "simple_module_hosting==0.0.19",
27
+ "simple_module_settings==0.0.19",
28
+ "simple_module_auth==0.0.19",
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.
@@ -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
@@ -18,6 +18,7 @@ from users.constants import (
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from fastapi import FastAPI
21
+ from simple_module_core.events import EventBus
21
22
 
22
23
  _MODULE_DEPENDENCY_AUTH = "Auth"
23
24
 
@@ -61,6 +62,30 @@ class UsersModule(ModuleBase):
61
62
 
62
63
  app.state.auth.auth_provider = UsersAuthProvider()
63
64
 
65
+ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
66
+ """Rebuild the OAuth client cache when the users settings reload.
67
+
68
+ Routes mount at construction (before DB hydration), so the cache is the
69
+ single source of truth at request time. Rebuilding it here lets an admin
70
+ add/remove a provider via the settings UI without a restart.
71
+ """
72
+ if app is None:
73
+ return
74
+
75
+ import importlib
76
+
77
+ settings_reloaded = importlib.import_module("settings.contracts.events").SettingsReloaded
78
+ from users.oauth.providers import build_client_map, provider_buttons
79
+
80
+ async def _rebuild_oauth_clients(event: settings_reloaded) -> None:
81
+ if event.package != "users":
82
+ return
83
+ state = app.state.users
84
+ state.oauth_clients = build_client_map(state.settings)
85
+ state.oauth_providers = provider_buttons(state.oauth_clients)
86
+
87
+ bus.subscribe(settings_reloaded, _rebuild_oauth_clients)
88
+
64
89
  def register_permissions(self, registry: PermissionRegistry) -> None:
65
90
  registry.add_group(
66
91
  "Users",
@@ -111,14 +136,6 @@ class UsersModule(ModuleBase):
111
136
  from users.contracts.schemas import UserCreate, UserRead
112
137
  from users.deps import fastapi_users
113
138
  from users.oauth.api import register_oauth_routes
114
- from users.settings import UsersSettings
115
-
116
- # Consumed only by ``register_oauth_routes`` → ``build_clients`` at
117
- # registration time, which reads class-attribute defaults captured by
118
- # ``env_str()`` at import. Request-time readers of mutable fields
119
- # (e.g. ``login_redirect_url``) must go through
120
- # ``request.app.state.users.settings``, not this instance.
121
- settings = UsersSettings()
122
139
 
123
140
  api_router.include_router(auth_local_api.router)
124
141
  api_router.include_router(token_router)
@@ -146,7 +163,7 @@ class UsersModule(ModuleBase):
146
163
  Depends(auth_local_api.enforce_auth_throughput_limit),
147
164
  ],
148
165
  )
149
- register_oauth_routes(api_router, settings)
166
+ register_oauth_routes(api_router)
150
167
 
151
168
  view_router.include_router(auth_views)
152
169
  view_router.include_router(admin_views)
@@ -160,7 +177,7 @@ class UsersModule(ModuleBase):
160
177
  from users.bootstrap import bootstrap_admin_from_env
161
178
  from users.deps import auth_backend
162
179
  from users.mailer import build_mailer
163
- from users.oauth.providers import enabled_provider_names
180
+ from users.oauth.providers import build_client_map, provider_buttons
164
181
  from users.roles_cache import refresh_roles_cache
165
182
 
166
183
  state = app.state.users
@@ -175,7 +192,8 @@ class UsersModule(ModuleBase):
175
192
  max_attempts=s.auth_rate_limit_attempts,
176
193
  window_seconds=s.auth_rate_limit_window_seconds,
177
194
  )
178
- state.oauth_providers = enabled_provider_names(s)
195
+ state.oauth_clients = build_client_map(s)
196
+ state.oauth_providers = provider_buttons(state.oauth_clients)
179
197
 
180
198
  # Auto-fall-back when the default ``/dashboard/`` target is
181
199
  # unreachable because the Dashboard module isn't installed (e.g.
@@ -0,0 +1,5 @@
1
+ """OAuth feature — public surface re-exported for backward compatibility."""
2
+
3
+ from users.oauth.providers import OAuthProvider, build_client_map, build_clients, provider_buttons
4
+
5
+ __all__ = ["OAuthProvider", "build_client_map", "build_clients", "provider_buttons"]
@@ -1,21 +1,22 @@
1
- """OAuth/OIDC login routes — one pair (``/login``, ``/callback``) per provider.
1
+ """OAuth/OIDC login routes — a single provider-agnostic dispatcher.
2
+
3
+ One pair of routes (``/auth/{provider}/login`` + ``/auth/{provider}/callback``)
4
+ serves every provider. The client is resolved per request from
5
+ ``app.state.users.oauth_clients`` — the cache built in ``UsersModule.on_startup``
6
+ from hydrated DB settings and rebuilt on ``SettingsReloaded``. Routes mount
7
+ unconditionally at construction (before settings hydrate), so providers
8
+ configured through the settings UI work and take effect without a restart.
2
9
 
3
10
  Why a custom handler rather than ``fastapi_users.get_oauth_router``: the stock
4
- router's ``/callback`` returns a 204 No Content with the auth cookie set. That
5
- works for SPA flows that redirect on a successful AJAX response, but Inertia
6
- expects the user's browser to land on a real page. Here ``/callback`` returns
7
- a 303 redirect to ``settings.login_redirect_url`` instead, with the same
8
- cookie attached.
9
-
10
- Find-or-create + email-association logic still goes through
11
- ``UserManager.oauth_callback`` — we don't reimplement it, only the transport
12
- around it. State CSRF uses Starlette's signed session cookie (already mounted
13
- by the framework) instead of fastapi-users' separate JWT-state cookie.
11
+ ``/callback`` returns 204; Inertia needs the browser to land on a real page, so
12
+ ``/callback`` returns a 303 redirect to ``login_redirect_url`` with the auth
13
+ cookie attached. Find-or-create + email-association go through
14
+ ``UserManager.oauth_callback``. State CSRF uses Starlette's signed session
15
+ cookie.
14
16
  """
15
17
 
16
18
  from __future__ import annotations
17
19
 
18
- import logging
19
20
  import secrets
20
21
  from typing import TYPE_CHECKING
21
22
 
@@ -24,40 +25,51 @@ from fastapi_users import exceptions as fu_exceptions
24
25
  from starlette.responses import RedirectResponse
25
26
 
26
27
  from users.deps import auth_backend, get_user_manager
27
- from users.oauth.providers import OAuthProvider, build_clients
28
28
 
29
29
  if TYPE_CHECKING:
30
30
  from users.manager import UserManager
31
- from users.settings import UsersSettings
32
-
33
- logger = logging.getLogger(__name__)
31
+ from users.oauth.providers import OAuthProvider
34
32
 
35
33
  _SESSION_STATE_KEY_FMT = "oauth_state:{provider}"
34
+ _CALLBACK_ROUTE_NAME = "users_oauth_callback"
35
+
36
+
37
+ def _resolve_provider(request: Request, provider: str) -> OAuthProvider:
38
+ """Return the configured provider by name, or raise 404."""
39
+ found = request.app.state.users.oauth_clients.get(provider)
40
+ if found is None:
41
+ raise HTTPException(
42
+ status_code=status.HTTP_404_NOT_FOUND, detail="OAUTH_PROVIDER_NOT_FOUND"
43
+ )
44
+ return found
36
45
 
37
46
 
38
- def _build_provider_router(provider: OAuthProvider) -> APIRouter:
39
- """Mount /login + /callback for one provider."""
40
- router = APIRouter()
41
- state_key = _SESSION_STATE_KEY_FMT.format(provider=provider.name)
47
+ def register_oauth_routes(api_router: APIRouter) -> None:
48
+ """Mount the provider-agnostic OAuth dispatcher under ``/auth``."""
49
+ router = APIRouter(prefix="/auth", tags=["users-auth"])
42
50
 
43
- @router.get("/login")
44
- async def begin(request: Request) -> RedirectResponse:
51
+ @router.get("/{provider}/login")
52
+ async def begin(request: Request, provider: str) -> RedirectResponse:
45
53
  """Generate a state nonce, stash it in the session, redirect to the IdP."""
54
+ provider_obj = _resolve_provider(request, provider)
46
55
  state = secrets.token_urlsafe(32)
47
- request.session[state_key] = state
48
- callback_url = str(request.url_for(f"oauth_{provider.name}_callback"))
49
- authorization_url = await provider.client.get_authorization_url(callback_url, state)
56
+ request.session[_SESSION_STATE_KEY_FMT.format(provider=provider)] = state
57
+ callback_url = str(request.url_for(_CALLBACK_ROUTE_NAME, provider=provider))
58
+ authorization_url = await provider_obj.client.get_authorization_url(callback_url, state)
50
59
  return RedirectResponse(authorization_url, status_code=302)
51
60
 
52
- @router.get("/callback", name=f"oauth_{provider.name}_callback")
61
+ @router.get("/{provider}/callback", name=_CALLBACK_ROUTE_NAME)
53
62
  async def callback(
54
63
  request: Request,
64
+ provider: str,
55
65
  code: str | None = None,
56
66
  state: str | None = None,
57
67
  user_manager: UserManager = Depends(get_user_manager),
58
68
  strategy=Depends(auth_backend.get_strategy),
59
69
  ) -> RedirectResponse:
60
70
  """Verify state, exchange code, find-or-create user, set cookie, redirect."""
71
+ provider_obj = _resolve_provider(request, provider)
72
+ state_key = _SESSION_STATE_KEY_FMT.format(provider=provider)
61
73
  expected_state = request.session.pop(state_key, None)
62
74
  if not state or not expected_state or not secrets.compare_digest(state, expected_state):
63
75
  raise HTTPException(
@@ -68,15 +80,15 @@ def _build_provider_router(provider: OAuthProvider) -> APIRouter:
68
80
  status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_MISSING_CODE"
69
81
  )
70
82
 
71
- callback_url = str(request.url_for(f"oauth_{provider.name}_callback"))
72
- token = await provider.client.get_access_token(code, callback_url)
73
- account_id, account_email = await provider.client.get_id_email(token["access_token"])
83
+ callback_url = str(request.url_for(_CALLBACK_ROUTE_NAME, provider=provider))
84
+ token = await provider_obj.client.get_access_token(code, callback_url)
85
+ account_id, account_email = await provider_obj.client.get_id_email(token["access_token"])
74
86
  if account_email is None:
75
87
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_NO_EMAIL")
76
88
 
77
89
  try:
78
90
  user = await user_manager.oauth_callback(
79
- provider.name,
91
+ provider,
80
92
  token["access_token"],
81
93
  account_id,
82
94
  account_email,
@@ -87,9 +99,6 @@ def _build_provider_router(provider: OAuthProvider) -> APIRouter:
87
99
  is_verified_by_default=True,
88
100
  )
89
101
  except fu_exceptions.UserAlreadyExists:
90
- # Email exists but associate_by_email=False would forbid linking.
91
- # We always pass True above, so this branch only fires if the
92
- # provider returns ambiguous data.
93
102
  raise HTTPException(
94
103
  status_code=status.HTTP_400_BAD_REQUEST,
95
104
  detail="OAUTH_USER_ALREADY_EXISTS",
@@ -100,14 +109,9 @@ def _build_provider_router(provider: OAuthProvider) -> APIRouter:
100
109
  status_code=status.HTTP_400_BAD_REQUEST, detail="LOGIN_BAD_CREDENTIALS"
101
110
  )
102
111
 
103
- # Set the auth cookie via the existing backend, then bridge the
104
- # session in on_after_login (sets session["user_id"] for AuthMiddleware).
105
112
  login_response = await auth_backend.login(strategy, user)
106
113
  await user_manager.on_after_login(user, request, login_response)
107
114
 
108
- # Read login_redirect_url lazily: ``UsersModule.on_startup`` may
109
- # mutate it (e.g. dashboard-fallback) after this router is mounted,
110
- # and admins can change it at runtime via the settings UI.
111
115
  redirect_url = request.app.state.users.settings.login_redirect_url
112
116
  redirect = RedirectResponse(redirect_url, status_code=303)
113
117
  for key, value in login_response.headers.items():
@@ -115,21 +119,4 @@ def _build_provider_router(provider: OAuthProvider) -> APIRouter:
115
119
  redirect.raw_headers.append((b"set-cookie", value.encode("latin-1")))
116
120
  return redirect
117
121
 
118
- return router
119
-
120
-
121
- def register_oauth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
122
- """Mount /auth/<provider>/{login,callback} for every configured provider."""
123
- providers = build_clients(settings)
124
- for provider in providers:
125
- api_router.include_router(
126
- _build_provider_router(provider),
127
- prefix=f"/auth/{provider.name}",
128
- tags=["users-auth"],
129
- )
130
- if providers:
131
- logger.info(
132
- "Registered %d OAuth provider(s): %s",
133
- len(providers),
134
- ", ".join(p.name for p in providers),
135
- )
122
+ api_router.include_router(router)
@@ -30,28 +30,6 @@ class OAuthProvider(NamedTuple):
30
30
  client: BaseOAuth2
31
31
 
32
32
 
33
- def enabled_provider_names(settings: UsersSettings) -> list[dict[str, str]]:
34
- """Return ``[{"name": ..., "display_name": ...}]`` for configured providers.
35
-
36
- Cheap settings-only check used by the login page to render social-login
37
- buttons. Does not construct clients or hit the network — that would be
38
- wasteful per page render and would fail-open if discovery is briefly
39
- unreachable.
40
- """
41
- out: list[dict[str, str]] = []
42
- if settings.oauth_google_client_id and settings.oauth_google_client_secret:
43
- out.append({"name": "google", "display_name": "Google"})
44
- if settings.oauth_github_client_id and settings.oauth_github_client_secret:
45
- out.append({"name": "github", "display_name": "GitHub"})
46
- if (
47
- settings.oauth_oidc_client_id
48
- and settings.oauth_oidc_client_secret
49
- and settings.oauth_oidc_discovery_url
50
- ):
51
- out.append({"name": "oidc", "display_name": settings.oauth_oidc_display_name or "OIDC"})
52
- return out
53
-
54
-
55
33
  def build_clients(settings: UsersSettings) -> list[OAuthProvider]:
56
34
  """Return one entry per provider that has both id and secret configured.
57
35
 
@@ -89,6 +67,22 @@ def build_clients(settings: UsersSettings) -> list[OAuthProvider]:
89
67
  )
90
68
  )
91
69
 
70
+ if settings.oauth_microsoft_client_id and settings.oauth_microsoft_client_secret:
71
+ from httpx_oauth.clients.microsoft import MicrosoftGraphOAuth2
72
+
73
+ out.append(
74
+ OAuthProvider(
75
+ "microsoft",
76
+ "Microsoft",
77
+ MicrosoftGraphOAuth2(
78
+ settings.oauth_microsoft_client_id,
79
+ settings.oauth_microsoft_client_secret,
80
+ tenant=settings.oauth_microsoft_tenant or "common",
81
+ name="microsoft",
82
+ ),
83
+ )
84
+ )
85
+
92
86
  if (
93
87
  settings.oauth_oidc_client_id
94
88
  and settings.oauth_oidc_client_secret
@@ -118,3 +112,13 @@ def build_clients(settings: UsersSettings) -> list[OAuthProvider]:
118
112
  )
119
113
 
120
114
  return out
115
+
116
+
117
+ def build_client_map(settings: UsersSettings) -> dict[str, OAuthProvider]:
118
+ """Configured providers keyed by name for O(1) request-time lookup."""
119
+ return {p.name: p for p in build_clients(settings)}
120
+
121
+
122
+ def provider_buttons(clients: dict[str, OAuthProvider]) -> list[dict[str, str]]:
123
+ """Login-button descriptors derived from a built client map."""
124
+ return [{"name": p.name, "display_name": p.display_name} for p in clients.values()]
@@ -88,20 +88,33 @@ class UsersSettings(BaseSettings):
88
88
  bootstrap_user_email: str = ""
89
89
  bootstrap_user_password: str = ""
90
90
 
91
- # OAuth / OIDC providers. Each provider is enabled by setting both client
92
- # id and secret; missing credentials = provider not registered. Resolved
93
- # at module-import time (env_str) because client secrets shouldn't ride
94
- # in the DB-backed settings table that admins can read via the UI.
95
- oauth_google_client_id: str = env_str("SM_USERS_OAUTH_GOOGLE_CLIENT_ID", "")
96
- oauth_google_client_secret: str = env_str("SM_USERS_OAUTH_GOOGLE_CLIENT_SECRET", "")
97
- oauth_github_client_id: str = env_str("SM_USERS_OAUTH_GITHUB_CLIENT_ID", "")
98
- oauth_github_client_secret: str = env_str("SM_USERS_OAUTH_GITHUB_CLIENT_SECRET", "")
99
- # Generic OIDC works with any provider that exposes a discovery URL
100
- # (Keycloak, Authentik, Auth0, Zitadel, Entra ID, ...).
101
- oauth_oidc_client_id: str = env_str("SM_USERS_OAUTH_OIDC_CLIENT_ID", "")
102
- oauth_oidc_client_secret: str = env_str("SM_USERS_OAUTH_OIDC_CLIENT_SECRET", "")
103
- oauth_oidc_discovery_url: str = env_str("SM_USERS_OAUTH_OIDC_DISCOVERY_URL", "")
104
- oauth_oidc_display_name: str = env_str("SM_USERS_OAUTH_OIDC_DISPLAY_NAME", "OIDC")
91
+ # OAuth / OIDC providers configured via the admin settings UI
92
+ # (/settings/modules Users). Credentials live in the DB-backed settings
93
+ # store and hydrate after boot; secret fields are masked in the UI (the
94
+ # same treatment the SMTP password gets). Provider changes apply live via
95
+ # the SettingsReloaded event — no restart (see users/module.py).
96
+ oauth_google_client_id: str = Field(default="", json_schema_extra={"group": "Google OAuth"})
97
+ oauth_google_client_secret: str = Field(default="", json_schema_extra={"group": "Google OAuth"})
98
+ oauth_github_client_id: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"})
99
+ oauth_github_client_secret: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"})
100
+ # Generic OIDC any provider that exposes a discovery URL
101
+ # (Keycloak, Authentik, Auth0, Zitadel, ...).
102
+ oauth_oidc_client_id: str = Field(default="", json_schema_extra={"group": "OIDC"})
103
+ oauth_oidc_client_secret: str = Field(default="", json_schema_extra={"group": "OIDC"})
104
+ oauth_oidc_discovery_url: str = Field(default="", json_schema_extra={"group": "OIDC"})
105
+ oauth_oidc_display_name: str = Field(default="OIDC", json_schema_extra={"group": "OIDC"})
106
+ # Microsoft Entra ID / Microsoft accounts. tenant: "common" (any work/school
107
+ # or personal account), "organizations" (work/school only), or a tenant GUID
108
+ # to restrict sign-in to a single Entra tenant.
109
+ oauth_microsoft_client_id: str = Field(
110
+ default="", json_schema_extra={"group": "Microsoft OAuth"}
111
+ )
112
+ oauth_microsoft_client_secret: str = Field(
113
+ default="", json_schema_extra={"group": "Microsoft OAuth"}
114
+ )
115
+ oauth_microsoft_tenant: str = Field(
116
+ default="common", json_schema_extra={"group": "Microsoft OAuth"}
117
+ )
105
118
 
106
119
  @model_validator(mode="after")
107
120
  def _forbid_placeholder_token_secrets_in_production(self) -> UsersSettings:
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
18
18
  if TYPE_CHECKING:
19
19
  from users.auth_local.rate_limit import LoginRateLimiter, ThroughputLimiter
20
20
  from users.mailer import Mailer
21
+ from users.oauth.providers import OAuthProvider
21
22
  from users.roles_cache import RoleSummary
22
23
  from users.settings import UsersSettings
23
24
 
@@ -32,3 +33,4 @@ class UsersState:
32
33
  auth_throughput_limiter: ThroughputLimiter | None = None
33
34
  roles_cache: list[RoleSummary] = field(default_factory=list)
34
35
  oauth_providers: list[dict[str, str]] = field(default_factory=list)
36
+ oauth_clients: dict[str, OAuthProvider] = field(default_factory=dict)
@@ -1,55 +0,0 @@
1
- # simple_module_users
2
-
3
- Email+password user management for [simple_module](https://github.com/antosubash/simple_module_python) apps. Replaces Keycloak/Auth0 for the common case: local accounts, admin invites, password reset, optional public signup. Built on `fastapi-users`.
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install simple_module_users
9
- ```
10
-
11
- Pre-wired into any app scaffolded with `smpy new`.
12
-
13
- ## What it provides
14
-
15
- - Email + password registration, login, logout, password reset.
16
- - Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
17
- - Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
18
- - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
19
- - `smpy users create-admin` CLI for ad-hoc admin creation.
20
- - Inertia pages for login/register/invite-accept/admin-invite.
21
- - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
22
-
23
- ## Usage
24
-
25
- CLI:
26
-
27
- ```bash
28
- uv run smpy users create-admin --email admin@example.com --password 'change-me'
29
- ```
30
-
31
- Bootstrap-on-boot (`.env`):
32
-
33
- ```
34
- SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
35
- SM_USERS_BOOTSTRAP_PASSWORD=change-me
36
- ```
37
-
38
- Program:
39
-
40
- ```python
41
- from users.deps import CurrentUser # type: ignore[import-not-found]
42
-
43
- @router.get("/profile")
44
- async def profile(user: CurrentUser):
45
- return {"email": user.email}
46
- ```
47
-
48
- ## Depends on
49
-
50
- - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
51
- - `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
52
-
53
- ## License
54
-
55
- MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -1,5 +0,0 @@
1
- """OAuth feature — public surface re-exported for backward compatibility."""
2
-
3
- from users.oauth.providers import OAuthProvider, build_clients, enabled_provider_names
4
-
5
- __all__ = ["OAuthProvider", "build_clients", "enabled_provider_names"]