simple-module-users 0.0.11__tar.gz → 0.0.12__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.11 → simple_module_users-0.0.12}/PKG-INFO +10 -10
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/README.md +3 -3
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/pyproject.toml +7 -7
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_cli.py +2 -2
- simple_module_users-0.0.12/tests/test_oauth.py +189 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/cli.py +4 -3
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/db_adapter.py +5 -2
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/endpoints/api.py +7 -1
- simple_module_users-0.0.12/users/endpoints/api_oauth.py +131 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/endpoints/views.py +2 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/manager.py +8 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/models/__init__.py +2 -0
- simple_module_users-0.0.12/users/models/oauth_account.py +41 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/models/user.py +12 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/module.py +5 -4
- simple_module_users-0.0.12/users/oauth.py +120 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/Login.tsx +24 -2
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/settings.py +16 -1
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/.gitignore +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/LICENSE +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/package.json +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/conftest.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_views.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/tsconfig.json +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/__init__.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/backend.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/constants.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/deps.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/endpoints/__init__.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/endpoints/api_admin.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/exceptions.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/middleware.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/models/_base.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/models/role.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/py.typed +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/rate_limit.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/service.py +0 -0
- {simple_module_users-0.0.11 → simple_module_users-0.0.12}/users/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.12
|
|
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
|
|
@@ -23,12 +23,12 @@ Classifier: Typing :: Typed
|
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: aiosmtplib>=3.0
|
|
25
25
|
Requires-Dist: cachetools>=5.3
|
|
26
|
-
Requires-Dist: fastapi-users[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.
|
|
26
|
+
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
27
|
+
Requires-Dist: simple-module-auth==0.0.12
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.12
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.12
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.12
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.12
|
|
32
32
|
Requires-Dist: typer>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -42,7 +42,7 @@ Email+password user management for [simple_module](https://github.com/antosubash
|
|
|
42
42
|
pip install simple_module_users
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Pre-wired into any app scaffolded with `
|
|
45
|
+
Pre-wired into any app scaffolded with `smpy new`.
|
|
46
46
|
|
|
47
47
|
## What it provides
|
|
48
48
|
|
|
@@ -50,7 +50,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
|
|
|
50
50
|
- Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
|
|
51
51
|
- Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
|
|
52
52
|
- Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
|
|
53
|
-
- `
|
|
53
|
+
- `smpy users create-admin` CLI for ad-hoc admin creation.
|
|
54
54
|
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
55
55
|
- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
|
|
56
56
|
|
|
@@ -59,7 +59,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
|
|
|
59
59
|
CLI:
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
uv run
|
|
62
|
+
uv run smpy users create-admin --email admin@example.com --password 'change-me'
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Bootstrap-on-boot (`.env`):
|
|
@@ -8,7 +8,7 @@ Email+password user management for [simple_module](https://github.com/antosubash
|
|
|
8
8
|
pip install simple_module_users
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Pre-wired into any app scaffolded with `
|
|
11
|
+
Pre-wired into any app scaffolded with `smpy new`.
|
|
12
12
|
|
|
13
13
|
## What it provides
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
|
|
|
16
16
|
- Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
|
|
17
17
|
- Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
|
|
18
18
|
- Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
|
|
19
|
-
- `
|
|
19
|
+
- `smpy users create-admin` CLI for ad-hoc admin creation.
|
|
20
20
|
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
21
21
|
- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
|
|
22
22
|
|
|
@@ -25,7 +25,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
|
|
|
25
25
|
CLI:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
uv run
|
|
28
|
+
uv run smpy users create-admin --email admin@example.com --password 'change-me'
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
Bootstrap-on-boot (`.env`):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.12"
|
|
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,15 +21,15 @@ 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.12",
|
|
25
|
+
"simple_module_db==0.0.12",
|
|
26
|
+
"simple_module_hosting==0.0.12",
|
|
27
|
+
"simple_module_settings==0.0.12",
|
|
28
|
+
"simple_module_auth==0.0.12",
|
|
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.
|
|
32
|
-
"fastapi-users[sqlalchemy]>=15,<16",
|
|
32
|
+
"fastapi-users[sqlalchemy,oauth]>=15,<16",
|
|
33
33
|
"aiosmtplib>=3.0",
|
|
34
34
|
"cachetools>=5.3",
|
|
35
35
|
"typer>=0.12",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for the
|
|
1
|
+
"""Tests for the smpy users CLI (users.cli).
|
|
2
2
|
|
|
3
3
|
Strategy: monkeypatch ``users.bootstrap.create_admin`` so tests do not need
|
|
4
4
|
a real database. This avoids the complexity of standing up a schema-stamped
|
|
@@ -161,7 +161,7 @@ def test_create_admin_missing_email() -> None:
|
|
|
161
161
|
|
|
162
162
|
|
|
163
163
|
def test_app_help() -> None:
|
|
164
|
-
"""``
|
|
164
|
+
"""``smpy users --help`` shows the top-level help text and lists create-admin."""
|
|
165
165
|
result = runner.invoke(app, ["--help"])
|
|
166
166
|
assert result.exit_code == 0
|
|
167
167
|
assert "create-admin" in result.output
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Unit + integration tests for the OAuth/OIDC plumbing.
|
|
2
|
+
|
|
3
|
+
Provider client construction and the /authorize+/callback ASGI flow are not
|
|
4
|
+
covered here because both depend on real httpx-oauth clients that hit the
|
|
5
|
+
network (token exchange, profile fetch). Those are best validated in a manual
|
|
6
|
+
QA pass against a dev IdP. What this file *does* cover:
|
|
7
|
+
|
|
8
|
+
- ``enabled_provider_names`` correctly reflects settings.
|
|
9
|
+
- ``build_clients`` instantiates the Google + GitHub clients when configured.
|
|
10
|
+
- ``OAuthAccount`` persists and FK-cascades on user delete.
|
|
11
|
+
- ``UserManager.oauth_callback`` (the find-or-create core fastapi-users helper
|
|
12
|
+
the route delegates to) creates a fresh user + linked OAuthAccount, and
|
|
13
|
+
associates by email when the user already exists.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
from fastapi_users.password import PasswordHelper
|
|
22
|
+
from sqlalchemy import select
|
|
23
|
+
from users.models import OAuthAccount, User
|
|
24
|
+
from users.oauth import build_clients, enabled_provider_names
|
|
25
|
+
from users.settings import UsersSettings
|
|
26
|
+
|
|
27
|
+
_pw = PasswordHelper()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Settings → provider list
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_enabled_provider_names_empty_by_default():
|
|
36
|
+
assert enabled_provider_names(UsersSettings()) == []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_enabled_provider_names_lists_configured_providers():
|
|
40
|
+
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",
|
|
45
|
+
)
|
|
46
|
+
names = [p["name"] for p in enabled_provider_names(s)]
|
|
47
|
+
assert names == ["google", "github"]
|
|
48
|
+
|
|
49
|
+
|
|
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) == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_enabled_provider_names_oidc_requires_discovery_url():
|
|
56
|
+
s = UsersSettings(
|
|
57
|
+
oauth_oidc_client_id="x",
|
|
58
|
+
oauth_oidc_client_secret="y",
|
|
59
|
+
# discovery_url unset → not registered
|
|
60
|
+
)
|
|
61
|
+
assert enabled_provider_names(s) == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# build_clients (no-network providers only)
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_build_clients_google_and_github():
|
|
70
|
+
s = UsersSettings(
|
|
71
|
+
oauth_google_client_id="g-id",
|
|
72
|
+
oauth_google_client_secret="g-secret",
|
|
73
|
+
oauth_github_client_id="gh-id",
|
|
74
|
+
oauth_github_client_secret="gh-secret",
|
|
75
|
+
)
|
|
76
|
+
providers = build_clients(s)
|
|
77
|
+
assert [p.name for p in providers] == ["google", "github"]
|
|
78
|
+
# Sanity-check that the underlying httpx-oauth client carries our id.
|
|
79
|
+
assert providers[0].client.client_id == "g-id"
|
|
80
|
+
assert providers[1].client.client_id == "gh-id"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# OAuthAccount persistence + cascade
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.anyio
|
|
89
|
+
async def test_oauth_account_round_trip_and_cascade(users_db):
|
|
90
|
+
user = User(
|
|
91
|
+
id=uuid.uuid4(),
|
|
92
|
+
email="oauth-rt@example.com",
|
|
93
|
+
hashed_password=_pw.hash("SecurePass1!"),
|
|
94
|
+
is_active=True,
|
|
95
|
+
is_verified=True,
|
|
96
|
+
)
|
|
97
|
+
users_db.add(user)
|
|
98
|
+
await users_db.commit()
|
|
99
|
+
|
|
100
|
+
account = OAuthAccount(
|
|
101
|
+
user_id=user.id,
|
|
102
|
+
oauth_name="google",
|
|
103
|
+
access_token="tok",
|
|
104
|
+
account_id="google-123",
|
|
105
|
+
account_email=user.email,
|
|
106
|
+
)
|
|
107
|
+
users_db.add(account)
|
|
108
|
+
await users_db.commit()
|
|
109
|
+
|
|
110
|
+
found = (
|
|
111
|
+
await users_db.execute(select(OAuthAccount).where(OAuthAccount.account_id == "google-123"))
|
|
112
|
+
).scalar_one()
|
|
113
|
+
assert found.user_id == user.id
|
|
114
|
+
|
|
115
|
+
# FK cascade: deleting the user removes the linked account.
|
|
116
|
+
await users_db.delete(user)
|
|
117
|
+
await users_db.commit()
|
|
118
|
+
remaining = (
|
|
119
|
+
await users_db.execute(select(OAuthAccount).where(OAuthAccount.account_id == "google-123"))
|
|
120
|
+
).scalar_one_or_none()
|
|
121
|
+
assert remaining is None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# UserManager.oauth_callback — the find-or-create flow the route delegates to
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _build_user_manager(app):
|
|
130
|
+
"""Construct a UserManager bound to the test app's DB session."""
|
|
131
|
+
from users.db_adapter import UserDatabaseWithRoles
|
|
132
|
+
from users.manager import UserManager
|
|
133
|
+
from users.models import OAuthAccount, User
|
|
134
|
+
|
|
135
|
+
session = app.state.sm.db.session_factory()
|
|
136
|
+
s = await session.__aenter__()
|
|
137
|
+
user_db = UserDatabaseWithRoles(s, User, OAuthAccount)
|
|
138
|
+
manager = UserManager(user_db, app.state.users.mailer, app.state.users.settings)
|
|
139
|
+
return manager, session, s
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.anyio
|
|
143
|
+
async def test_oauth_callback_creates_new_user_and_account(users_app):
|
|
144
|
+
manager, session, _ = await _build_user_manager(users_app)
|
|
145
|
+
try:
|
|
146
|
+
user = await manager.oauth_callback(
|
|
147
|
+
"google",
|
|
148
|
+
access_token="tok",
|
|
149
|
+
account_id="google-new-1",
|
|
150
|
+
account_email="newuser@example.com",
|
|
151
|
+
associate_by_email=True,
|
|
152
|
+
is_verified_by_default=True,
|
|
153
|
+
)
|
|
154
|
+
assert user.email == "newuser@example.com"
|
|
155
|
+
assert user.is_verified is True
|
|
156
|
+
assert len(user.oauth_accounts) == 1
|
|
157
|
+
assert user.oauth_accounts[0].oauth_name == "google"
|
|
158
|
+
assert user.oauth_accounts[0].account_id == "google-new-1"
|
|
159
|
+
finally:
|
|
160
|
+
await session.__aexit__(None, None, None)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.anyio
|
|
164
|
+
async def test_oauth_callback_links_to_existing_email(users_app, users_db):
|
|
165
|
+
existing = User(
|
|
166
|
+
id=uuid.uuid4(),
|
|
167
|
+
email="existing@example.com",
|
|
168
|
+
hashed_password=_pw.hash("SecurePass1!"),
|
|
169
|
+
is_active=True,
|
|
170
|
+
is_verified=True,
|
|
171
|
+
)
|
|
172
|
+
users_db.add(existing)
|
|
173
|
+
await users_db.commit()
|
|
174
|
+
|
|
175
|
+
manager, session, _ = await _build_user_manager(users_app)
|
|
176
|
+
try:
|
|
177
|
+
linked = await manager.oauth_callback(
|
|
178
|
+
"github",
|
|
179
|
+
access_token="tok",
|
|
180
|
+
account_id="gh-42",
|
|
181
|
+
account_email="existing@example.com",
|
|
182
|
+
associate_by_email=True,
|
|
183
|
+
is_verified_by_default=True,
|
|
184
|
+
)
|
|
185
|
+
assert linked.id == existing.id
|
|
186
|
+
names = [a.oauth_name for a in linked.oauth_accounts]
|
|
187
|
+
assert names == ["github"]
|
|
188
|
+
finally:
|
|
189
|
+
await session.__aexit__(None, None, None)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Command-line entry points for the users module.
|
|
2
2
|
|
|
3
|
-
Exposed via ``
|
|
3
|
+
Exposed via ``smpy users`` (see pyproject.toml
|
|
4
|
+
[project.entry-points.simple_module_cli.cli_plugins]).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
smpy users create-admin --email a@b.test --password sekret [--full-name Me]
|
|
7
|
+
smpy users create-admin --email a@b.test --password new --force
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -12,7 +12,7 @@ from sqlalchemy import func, select
|
|
|
12
12
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
13
|
from sqlalchemy.orm import selectinload
|
|
14
14
|
|
|
15
|
-
from users.models import User, UserAccessToken
|
|
15
|
+
from users.models import OAuthAccount, User, UserAccessToken
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class UserDatabaseWithRoles(SQLAlchemyUserDatabase):
|
|
@@ -39,7 +39,10 @@ class UserDatabaseWithRoles(SQLAlchemyUserDatabase):
|
|
|
39
39
|
async def get_user_db(
|
|
40
40
|
session: AsyncSession = Depends(get_db),
|
|
41
41
|
) -> AsyncGenerator[UserDatabaseWithRoles, None]:
|
|
42
|
-
|
|
42
|
+
# OAuthAccount enables fastapi-users' OAuth router (get_by_oauth_account /
|
|
43
|
+
# add_oauth_account / update_oauth_account). Password-only flows are
|
|
44
|
+
# unaffected — those code paths never touch oauth_account_table.
|
|
45
|
+
yield UserDatabaseWithRoles(session, User, OAuthAccount)
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
async def get_access_token_db(
|
|
@@ -30,8 +30,10 @@ from users.deps import (
|
|
|
30
30
|
get_user_manager,
|
|
31
31
|
)
|
|
32
32
|
from users.endpoints.api_admin import admin_router
|
|
33
|
+
from users.endpoints.api_oauth import register_oauth_routes
|
|
33
34
|
from users.manager import UserManager
|
|
34
35
|
from users.rate_limit import LoginRateLimiter, ThroughputLimiter
|
|
36
|
+
from users.settings import UsersSettings
|
|
35
37
|
|
|
36
38
|
logger = logging.getLogger(__name__)
|
|
37
39
|
router = APIRouter()
|
|
@@ -125,7 +127,7 @@ auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=T
|
|
|
125
127
|
router.include_router(auth_inner, prefix="/auth-inner")
|
|
126
128
|
|
|
127
129
|
|
|
128
|
-
def register_auth_routes(api_router: APIRouter) -> None:
|
|
130
|
+
def register_auth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
|
|
129
131
|
"""Mount all auth routes.
|
|
130
132
|
|
|
131
133
|
The stock fastapi-users routers (reset/verify/register) ship POST endpoints
|
|
@@ -137,6 +139,9 @@ def register_auth_routes(api_router: APIRouter) -> None:
|
|
|
137
139
|
|
|
138
140
|
The register router is always mounted; ``require_signup_enabled`` gates
|
|
139
141
|
it at request time so ``allow_signup`` is hot-reloadable.
|
|
142
|
+
|
|
143
|
+
OAuth providers configured in ``settings`` are mounted under
|
|
144
|
+
``/auth/<provider>/{login,callback}`` — see :mod:`users.endpoints.api_oauth`.
|
|
140
145
|
"""
|
|
141
146
|
api_router.include_router(router)
|
|
142
147
|
api_router.include_router(
|
|
@@ -160,6 +165,7 @@ def register_auth_routes(api_router: APIRouter) -> None:
|
|
|
160
165
|
Depends(enforce_auth_throughput_limit),
|
|
161
166
|
],
|
|
162
167
|
)
|
|
168
|
+
register_oauth_routes(api_router, settings)
|
|
163
169
|
|
|
164
170
|
|
|
165
171
|
# ── Accept-invite (verify + set password + login, one shot) ─────────────────
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""OAuth/OIDC login routes — one pair (``/login``, ``/callback``) per provider.
|
|
2
|
+
|
|
3
|
+
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.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import secrets
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
23
|
+
from fastapi_users import exceptions as fu_exceptions
|
|
24
|
+
from starlette.responses import RedirectResponse
|
|
25
|
+
|
|
26
|
+
from users.deps import auth_backend, get_user_manager
|
|
27
|
+
from users.oauth import OAuthProvider, build_clients
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from users.manager import UserManager
|
|
31
|
+
from users.settings import UsersSettings
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_SESSION_STATE_KEY_FMT = "oauth_state:{provider}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_provider_router(provider: OAuthProvider, login_redirect_url: str) -> APIRouter:
|
|
39
|
+
"""Mount /login + /callback for one provider."""
|
|
40
|
+
router = APIRouter()
|
|
41
|
+
state_key = _SESSION_STATE_KEY_FMT.format(provider=provider.name)
|
|
42
|
+
|
|
43
|
+
@router.get("/login")
|
|
44
|
+
async def begin(request: Request) -> RedirectResponse:
|
|
45
|
+
"""Generate a state nonce, stash it in the session, redirect to the IdP."""
|
|
46
|
+
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)
|
|
50
|
+
return RedirectResponse(authorization_url, status_code=302)
|
|
51
|
+
|
|
52
|
+
@router.get("/callback", name=f"oauth_{provider.name}_callback")
|
|
53
|
+
async def callback(
|
|
54
|
+
request: Request,
|
|
55
|
+
code: str | None = None,
|
|
56
|
+
state: str | None = None,
|
|
57
|
+
user_manager: UserManager = Depends(get_user_manager),
|
|
58
|
+
strategy=Depends(auth_backend.get_strategy),
|
|
59
|
+
) -> RedirectResponse:
|
|
60
|
+
"""Verify state, exchange code, find-or-create user, set cookie, redirect."""
|
|
61
|
+
expected_state = request.session.pop(state_key, None)
|
|
62
|
+
if not state or not expected_state or not secrets.compare_digest(state, expected_state):
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_INVALID_STATE"
|
|
65
|
+
)
|
|
66
|
+
if not code:
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_MISSING_CODE"
|
|
69
|
+
)
|
|
70
|
+
|
|
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"])
|
|
74
|
+
if account_email is None:
|
|
75
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_NO_EMAIL")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
user = await user_manager.oauth_callback(
|
|
79
|
+
provider.name,
|
|
80
|
+
token["access_token"],
|
|
81
|
+
account_id,
|
|
82
|
+
account_email,
|
|
83
|
+
token.get("expires_at"),
|
|
84
|
+
token.get("refresh_token"),
|
|
85
|
+
request,
|
|
86
|
+
associate_by_email=True,
|
|
87
|
+
is_verified_by_default=True,
|
|
88
|
+
)
|
|
89
|
+
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
|
+
raise HTTPException(
|
|
94
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
95
|
+
detail="OAUTH_USER_ALREADY_EXISTS",
|
|
96
|
+
) from None
|
|
97
|
+
|
|
98
|
+
if not user.is_active:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="LOGIN_BAD_CREDENTIALS"
|
|
101
|
+
)
|
|
102
|
+
|
|
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
|
+
login_response = await auth_backend.login(strategy, user)
|
|
106
|
+
await user_manager.on_after_login(user, request, login_response)
|
|
107
|
+
|
|
108
|
+
redirect = RedirectResponse(login_redirect_url, status_code=303)
|
|
109
|
+
for key, value in login_response.headers.items():
|
|
110
|
+
if key.lower() == "set-cookie":
|
|
111
|
+
redirect.raw_headers.append((b"set-cookie", value.encode("latin-1")))
|
|
112
|
+
return redirect
|
|
113
|
+
|
|
114
|
+
return router
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register_oauth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
|
|
118
|
+
"""Mount /auth/<provider>/{login,callback} for every configured provider."""
|
|
119
|
+
providers = build_clients(settings)
|
|
120
|
+
for provider in providers:
|
|
121
|
+
api_router.include_router(
|
|
122
|
+
_build_provider_router(provider, settings.login_redirect_url),
|
|
123
|
+
prefix=f"/auth/{provider.name}",
|
|
124
|
+
tags=["users-auth"],
|
|
125
|
+
)
|
|
126
|
+
if providers:
|
|
127
|
+
logger.info(
|
|
128
|
+
"Registered %d OAuth provider(s): %s",
|
|
129
|
+
len(providers),
|
|
130
|
+
", ".join(p.name for p in providers),
|
|
131
|
+
)
|
|
@@ -14,6 +14,7 @@ from starlette.responses import RedirectResponse
|
|
|
14
14
|
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
|
|
15
15
|
from users.deps import get_user_service
|
|
16
16
|
from users.exceptions import UserNotFoundError
|
|
17
|
+
from users.oauth import enabled_provider_names
|
|
17
18
|
from users.roles_cache import get_roles_cache
|
|
18
19
|
from users.service import UserService
|
|
19
20
|
|
|
@@ -72,6 +73,7 @@ async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse:
|
|
|
72
73
|
"allow_signup": users_settings.allow_signup,
|
|
73
74
|
"dev_accounts": dev_accounts,
|
|
74
75
|
"login_redirect_url": users_settings.login_redirect_url,
|
|
76
|
+
"oauth_providers": enabled_provider_names(users_settings),
|
|
75
77
|
},
|
|
76
78
|
)
|
|
77
79
|
|
|
@@ -11,6 +11,7 @@ from fastapi import Depends, Request
|
|
|
11
11
|
from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions
|
|
12
12
|
from fastapi_users.jwt import generate_jwt
|
|
13
13
|
|
|
14
|
+
from users.constants import SESSION_USER_ID_KEY
|
|
14
15
|
from users.contracts.events import UserRegistered
|
|
15
16
|
from users.db_adapter import UserDatabaseWithRoles, get_user_db
|
|
16
17
|
from users.mailer import Mailer
|
|
@@ -86,6 +87,13 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|
|
86
87
|
) -> None:
|
|
87
88
|
user.last_login_at = datetime.now(UTC)
|
|
88
89
|
await self.user_db.update(user, {"last_login_at": user.last_login_at})
|
|
90
|
+
# Bridge to AuthMiddleware: it reads session["user_id"] (not the
|
|
91
|
+
# fastapi-users cookie) to identify the request principal. Setting it
|
|
92
|
+
# here covers OAuth callbacks too, where there's no wrapper to do it
|
|
93
|
+
# explicitly. Password / accept-invite flows already set this in their
|
|
94
|
+
# wrappers — re-assigning the same value here is a harmless no-op.
|
|
95
|
+
if request is not None:
|
|
96
|
+
request.session[SESSION_USER_ID_KEY] = str(user.id)
|
|
89
97
|
|
|
90
98
|
# ── Token helpers (no email side-effect) ─────────────────
|
|
91
99
|
|
|
@@ -9,12 +9,14 @@ from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDataba
|
|
|
9
9
|
|
|
10
10
|
from users.models._base import Base
|
|
11
11
|
from users.models.access_token import UserAccessToken
|
|
12
|
+
from users.models.oauth_account import OAuthAccount
|
|
12
13
|
from users.models.role import Role
|
|
13
14
|
from users.models.user import User
|
|
14
15
|
from users.models.user_role import UserRole
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
17
18
|
"Base",
|
|
19
|
+
"OAuthAccount",
|
|
18
20
|
"Role",
|
|
19
21
|
"SQLAlchemyAccessTokenDatabase",
|
|
20
22
|
"SQLAlchemyUserDatabase",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""OAuth account table — links a (provider, account_id) pair to a User row.
|
|
2
|
+
|
|
3
|
+
Column surface mirrors fastapi-users' ``SQLAlchemyBaseOAuthAccountTableUUID``
|
|
4
|
+
so ``SQLAlchemyUserDatabase`` binds to it without inheriting from the upstream
|
|
5
|
+
base class (whose ``Mapped[...]`` columns are incompatible with SQLModel's
|
|
6
|
+
metaclass — same constraint as ``User`` / ``UserAccessToken``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# NOTE: intentionally no ``from __future__ import annotations`` — SQLModel
|
|
10
|
+
# Relationship resolution requires runtime annotations.
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from fastapi_users_db_sqlalchemy.generics import GUID
|
|
15
|
+
from sqlmodel import Field
|
|
16
|
+
|
|
17
|
+
from users.models._base import Base
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OAuthAccount(Base, table=True): # ty: ignore[unsupported-base]
|
|
21
|
+
"""One row per (provider, account_id) link to a local User."""
|
|
22
|
+
|
|
23
|
+
__tablename__ = "users_oauth_account"
|
|
24
|
+
|
|
25
|
+
id: uuid.UUID = Field(
|
|
26
|
+
default_factory=uuid.uuid4,
|
|
27
|
+
sa_type=GUID,
|
|
28
|
+
primary_key=True,
|
|
29
|
+
)
|
|
30
|
+
user_id: uuid.UUID = Field(
|
|
31
|
+
sa_type=GUID,
|
|
32
|
+
foreign_key="users_user.id",
|
|
33
|
+
ondelete="CASCADE",
|
|
34
|
+
index=True,
|
|
35
|
+
)
|
|
36
|
+
oauth_name: str = Field(max_length=100, index=True)
|
|
37
|
+
access_token: str = Field(max_length=1024)
|
|
38
|
+
expires_at: int | None = Field(default=None)
|
|
39
|
+
refresh_token: str | None = Field(default=None, max_length=1024)
|
|
40
|
+
account_id: str = Field(max_length=320, index=True)
|
|
41
|
+
account_email: str = Field(max_length=320)
|
|
@@ -25,6 +25,7 @@ from users.models.user_role import UserRole
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
# Resolved at runtime by SQLModel via the string forward ref;
|
|
27
27
|
# this import only feeds the type checker.
|
|
28
|
+
from users.models.oauth_account import OAuthAccount
|
|
28
29
|
from users.models.role import Role
|
|
29
30
|
|
|
30
31
|
|
|
@@ -62,6 +63,17 @@ class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
|
62
63
|
sa_relationship_kwargs={"lazy": "noload"},
|
|
63
64
|
)
|
|
64
65
|
|
|
66
|
+
# fastapi-users' SQLAlchemyUserDatabase.add_oauth_account does
|
|
67
|
+
# ``user.oauth_accounts.append(...)``, so this attribute must exist.
|
|
68
|
+
# ``selectin`` so the OAuth router can read the list without an
|
|
69
|
+
# implicit async lazy-load.
|
|
70
|
+
oauth_accounts: list["OAuthAccount"] = Relationship(
|
|
71
|
+
sa_relationship_kwargs={
|
|
72
|
+
"lazy": "selectin",
|
|
73
|
+
"cascade": "all, delete-orphan",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
65
77
|
# Functional index so the ``lower(email)`` predicate used by
|
|
66
78
|
# ``UserDatabaseWithRoles.get_by_email`` can be served from an index.
|
|
67
79
|
__table_args__ = (Index("ix_users_user_email_lower", text("lower(email)")),)
|
|
@@ -112,11 +112,12 @@ class UsersModule(ModuleBase):
|
|
|
112
112
|
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
113
113
|
from users.endpoints.api import register_auth_routes
|
|
114
114
|
from users.endpoints.views import router as views
|
|
115
|
+
from users.settings import UsersSettings
|
|
115
116
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
register_auth_routes(api_router)
|
|
117
|
+
# Construct settings here (re-reads env_str-bound fields like OAuth
|
|
118
|
+
# client ids/secrets). Validators have already passed by this point —
|
|
119
|
+
# ``register_settings`` ran first and would have raised on placeholders.
|
|
120
|
+
register_auth_routes(api_router, UsersSettings())
|
|
120
121
|
view_router.include_router(views)
|
|
121
122
|
|
|
122
123
|
def register_middleware(self, app: FastAPI) -> None:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""OAuth/OIDC provider client factory.
|
|
2
|
+
|
|
3
|
+
Constructs the ``httpx_oauth`` clients for every provider that has both
|
|
4
|
+
``client_id`` and ``client_secret`` set in :class:`UsersSettings`. A provider
|
|
5
|
+
with no credentials is silently skipped — that's the "feature flag" knob.
|
|
6
|
+
|
|
7
|
+
Lives in its own module so :func:`UsersModule.register_routes` can import it
|
|
8
|
+
without dragging the heavy ``httpx_oauth`` packages into the cold-start path
|
|
9
|
+
when no provider is configured.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from httpx_oauth.oauth2 import BaseOAuth2
|
|
19
|
+
|
|
20
|
+
from users.settings import UsersSettings
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OAuthProvider(NamedTuple):
|
|
26
|
+
"""One configured provider — name is the URL segment (``/auth/<name>``)."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
display_name: str
|
|
30
|
+
client: BaseOAuth2
|
|
31
|
+
|
|
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
|
+
def build_clients(settings: UsersSettings) -> list[OAuthProvider]:
|
|
56
|
+
"""Return one entry per provider that has both id and secret configured.
|
|
57
|
+
|
|
58
|
+
The generic OIDC provider also requires a discovery URL. If discovery
|
|
59
|
+
fetch fails at construction time, the provider is logged and skipped
|
|
60
|
+
rather than raising — a misconfigured IdP must not break boot.
|
|
61
|
+
"""
|
|
62
|
+
out: list[OAuthProvider] = []
|
|
63
|
+
|
|
64
|
+
if settings.oauth_google_client_id and settings.oauth_google_client_secret:
|
|
65
|
+
from httpx_oauth.clients.google import GoogleOAuth2
|
|
66
|
+
|
|
67
|
+
out.append(
|
|
68
|
+
OAuthProvider(
|
|
69
|
+
"google",
|
|
70
|
+
"Google",
|
|
71
|
+
GoogleOAuth2(
|
|
72
|
+
settings.oauth_google_client_id,
|
|
73
|
+
settings.oauth_google_client_secret,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if settings.oauth_github_client_id and settings.oauth_github_client_secret:
|
|
79
|
+
from httpx_oauth.clients.github import GitHubOAuth2
|
|
80
|
+
|
|
81
|
+
out.append(
|
|
82
|
+
OAuthProvider(
|
|
83
|
+
"github",
|
|
84
|
+
"GitHub",
|
|
85
|
+
GitHubOAuth2(
|
|
86
|
+
settings.oauth_github_client_id,
|
|
87
|
+
settings.oauth_github_client_secret,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
settings.oauth_oidc_client_id
|
|
94
|
+
and settings.oauth_oidc_client_secret
|
|
95
|
+
and settings.oauth_oidc_discovery_url
|
|
96
|
+
):
|
|
97
|
+
from httpx_oauth.clients.openid import OpenID, OpenIDConfigurationError
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
client = OpenID(
|
|
101
|
+
settings.oauth_oidc_client_id,
|
|
102
|
+
settings.oauth_oidc_client_secret,
|
|
103
|
+
settings.oauth_oidc_discovery_url,
|
|
104
|
+
name="oidc",
|
|
105
|
+
)
|
|
106
|
+
except OpenIDConfigurationError:
|
|
107
|
+
logger.exception(
|
|
108
|
+
"OIDC discovery failed for %s — provider disabled",
|
|
109
|
+
settings.oauth_oidc_discovery_url,
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
out.append(
|
|
113
|
+
OAuthProvider(
|
|
114
|
+
"oidc",
|
|
115
|
+
settings.oauth_oidc_display_name or "OIDC",
|
|
116
|
+
client,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return out
|
|
@@ -12,15 +12,22 @@ interface DevAccount {
|
|
|
12
12
|
password: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
interface OAuthProvider {
|
|
16
|
+
name: string;
|
|
17
|
+
display_name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
interface Props {
|
|
16
21
|
allow_signup: boolean;
|
|
17
22
|
dev_accounts: DevAccount[];
|
|
18
23
|
login_redirect_url: string;
|
|
24
|
+
oauth_providers: OAuthProvider[];
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
function Login() {
|
|
22
|
-
const { allow_signup, dev_accounts, login_redirect_url } = usePage<{
|
|
23
|
-
|
|
28
|
+
const { allow_signup, dev_accounts, login_redirect_url, oauth_providers } = usePage<{
|
|
29
|
+
props: Props;
|
|
30
|
+
}>().props as unknown as Props;
|
|
24
31
|
|
|
25
32
|
const [email, setEmail] = useState('');
|
|
26
33
|
const [password, setPassword] = useState('');
|
|
@@ -156,6 +163,21 @@ function Login() {
|
|
|
156
163
|
</p>
|
|
157
164
|
)}
|
|
158
165
|
|
|
166
|
+
{oauth_providers && oauth_providers.length > 0 && (
|
|
167
|
+
<div className="mt-5 border-t border-border pt-4">
|
|
168
|
+
<p className="mb-2 text-center font-mono text-[11px] text-muted-foreground">
|
|
169
|
+
Or continue with
|
|
170
|
+
</p>
|
|
171
|
+
<div className="flex flex-col gap-2">
|
|
172
|
+
{oauth_providers.map((p) => (
|
|
173
|
+
<Button key={p.name} type="button" variant="outline" asChild disabled={loading}>
|
|
174
|
+
<a href={`/api/users/auth/${p.name}/login`}>{p.display_name}</a>
|
|
175
|
+
</Button>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
159
181
|
{dev_accounts && dev_accounts.length > 0 && (
|
|
160
182
|
<div className="mt-5 border-t border-border pt-4">
|
|
161
183
|
<p className="mb-2 text-center font-mono text-[11px] text-muted-foreground">
|
|
@@ -31,7 +31,7 @@ class UsersSettings(BaseSettings):
|
|
|
31
31
|
require_verification: bool = True
|
|
32
32
|
|
|
33
33
|
# Where the login page sends a successful sign-in. Sites without the
|
|
34
|
-
# bundled ``dashboard`` module (``
|
|
34
|
+
# bundled ``dashboard`` module (``smpy new --preset minimal``) override
|
|
35
35
|
# this to wherever their post-login landing lives.
|
|
36
36
|
login_redirect_url: str = "/dashboard/"
|
|
37
37
|
|
|
@@ -84,6 +84,21 @@ class UsersSettings(BaseSettings):
|
|
|
84
84
|
bootstrap_user_email: str = ""
|
|
85
85
|
bootstrap_user_password: str = ""
|
|
86
86
|
|
|
87
|
+
# OAuth / OIDC providers. Each provider is enabled by setting both client
|
|
88
|
+
# id and secret; missing credentials = provider not registered. Resolved
|
|
89
|
+
# at module-import time (env_str) because client secrets shouldn't ride
|
|
90
|
+
# in the DB-backed settings table that admins can read via the UI.
|
|
91
|
+
oauth_google_client_id: str = env_str("SM_USERS_OAUTH_GOOGLE_CLIENT_ID", "")
|
|
92
|
+
oauth_google_client_secret: str = env_str("SM_USERS_OAUTH_GOOGLE_CLIENT_SECRET", "")
|
|
93
|
+
oauth_github_client_id: str = env_str("SM_USERS_OAUTH_GITHUB_CLIENT_ID", "")
|
|
94
|
+
oauth_github_client_secret: str = env_str("SM_USERS_OAUTH_GITHUB_CLIENT_SECRET", "")
|
|
95
|
+
# Generic OIDC — works with any provider that exposes a discovery URL
|
|
96
|
+
# (Keycloak, Authentik, Auth0, Zitadel, Entra ID, ...).
|
|
97
|
+
oauth_oidc_client_id: str = env_str("SM_USERS_OAUTH_OIDC_CLIENT_ID", "")
|
|
98
|
+
oauth_oidc_client_secret: str = env_str("SM_USERS_OAUTH_OIDC_CLIENT_SECRET", "")
|
|
99
|
+
oauth_oidc_discovery_url: str = env_str("SM_USERS_OAUTH_OIDC_DISCOVERY_URL", "")
|
|
100
|
+
oauth_oidc_display_name: str = env_str("SM_USERS_OAUTH_OIDC_DISPLAY_NAME", "OIDC")
|
|
101
|
+
|
|
87
102
|
@model_validator(mode="after")
|
|
88
103
|
def _forbid_placeholder_token_secrets_in_production(self) -> UsersSettings:
|
|
89
104
|
"""Fail boot if the reset/verify token secrets are still placeholders.
|
|
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
|
|
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.11 → simple_module_users-0.0.12}/users/mailer/templates/reset_password.txt
RENAMED
|
File without changes
|
{simple_module_users-0.0.11 → simple_module_users-0.0.12}/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
|