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.
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/PKG-INFO +40 -9
- simple_module_users-0.0.19/README.md +86 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/pyproject.toml +6 -6
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_oauth.py +64 -24
- simple_module_users-0.0.19/tests/test_oauth_routes.py +105 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/module.py +29 -11
- simple_module_users-0.0.19/users/oauth/__init__.py +5 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/oauth/api.py +43 -56
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/oauth/providers.py +26 -22
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/settings.py +27 -14
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/state.py +2 -0
- simple_module_users-0.0.17/README.md +0 -55
- simple_module_users-0.0.17/users/oauth/__init__.py +0 -5
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/.gitignore +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/LICENSE +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/package.json +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/conftest.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_views.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/tsconfig.json +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/__init__.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/api.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/service.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/views.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/token_api.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/backend.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/cli.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/constants.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/deps.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/exceptions.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/manager.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/middleware.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/_base.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/role.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/user.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/provider.py +0 -0
- {simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/py.typed +0 -0
- {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.
|
|
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.
|
|
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.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
|
|
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.
|
|
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.
|
|
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.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
|
|
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
|
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
"""OAuth/OIDC login routes —
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
cookie
|
|
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.
|
|
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
|
|
39
|
-
"""Mount
|
|
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[
|
|
48
|
-
callback_url = str(request.url_for(
|
|
49
|
-
authorization_url = await
|
|
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=
|
|
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(
|
|
72
|
-
token = await
|
|
73
|
-
account_id, account_email = await
|
|
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
|
|
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
|
-
|
|
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
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_bootstrap_resolution.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.17 → simple_module_users-0.0.19}/tests/test_users_middleware_resolvers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/IndexFilters.tsx
RENAMED
|
File without changes
|
{simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/RolesTab.tsx
RENAMED
|
File without changes
|
{simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/admin/components/UserRow.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/reset_password.txt
RENAMED
|
File without changes
|
{simple_module_users-0.0.17 → simple_module_users-0.0.19}/users/mailer/templates/verify_email.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|