simple-module-users 0.0.11__tar.gz → 0.0.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/PKG-INFO +10 -10
  2. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/README.md +3 -3
  3. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/pyproject.toml +7 -7
  4. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_cli.py +2 -2
  5. simple_module_users-0.0.13/tests/test_oauth.py +189 -0
  6. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/cli.py +4 -3
  7. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/db_adapter.py +5 -2
  8. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/endpoints/api.py +7 -1
  9. simple_module_users-0.0.13/users/endpoints/api_oauth.py +131 -0
  10. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/endpoints/views.py +2 -0
  11. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/manager.py +8 -0
  12. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/models/__init__.py +2 -0
  13. simple_module_users-0.0.13/users/models/oauth_account.py +41 -0
  14. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/models/user.py +12 -0
  15. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/module.py +5 -4
  16. simple_module_users-0.0.13/users/oauth.py +120 -0
  17. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/Login.tsx +24 -2
  18. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/settings.py +16 -1
  19. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/.gitignore +0 -0
  20. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/LICENSE +0 -0
  21. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/package.json +0 -0
  22. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/.gitkeep +0 -0
  23. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/_middleware_support.py +0 -0
  24. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/conftest.py +0 -0
  25. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_access_token_model.py +0 -0
  26. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_api_admin.py +0 -0
  27. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_api_admin_filters.py +0 -0
  28. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_api_auth.py +0 -0
  29. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_backend.py +0 -0
  30. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_bootstrap.py +0 -0
  31. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_constants.py +0 -0
  32. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_db_adapter.py +0 -0
  33. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_invite_flow.py +0 -0
  34. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_mailer.py +0 -0
  35. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_rate_limit.py +0 -0
  36. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_role_model.py +0 -0
  37. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_service_admin.py +0 -0
  38. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_settings.py +0 -0
  39. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_user_manager.py +0 -0
  40. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_user_model.py +0 -0
  41. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_user_role_model.py +0 -0
  42. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_user_service.py +0 -0
  43. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_users_deps.py +0 -0
  44. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_users_middleware.py +0 -0
  45. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_users_middleware_public_paths.py +0 -0
  46. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_views.py +0 -0
  47. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tests/test_views_admin.py +0 -0
  48. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/tsconfig.json +0 -0
  49. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/__init__.py +0 -0
  50. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/backend.py +0 -0
  51. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/bootstrap.py +0 -0
  52. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/components/IndexFilters.tsx +0 -0
  53. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/components/RolesTab.tsx +0 -0
  54. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/components/UserRow.tsx +0 -0
  55. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/constants.py +0 -0
  56. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/contracts/__init__.py +0 -0
  57. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/contracts/events.py +0 -0
  58. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/contracts/schemas.py +0 -0
  59. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/deps.py +0 -0
  60. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/endpoints/__init__.py +0 -0
  61. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/endpoints/api_admin.py +0 -0
  62. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/exceptions.py +0 -0
  63. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/mailer/__init__.py +0 -0
  64. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/mailer/console.py +0 -0
  65. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/mailer/smtp.py +0 -0
  66. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/mailer/templates/.gitkeep +0 -0
  67. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/mailer/templates/invite.txt +0 -0
  68. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/mailer/templates/reset_password.txt +0 -0
  69. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/mailer/templates/verify_email.txt +0 -0
  70. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/middleware.py +0 -0
  71. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/models/_base.py +0 -0
  72. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/models/access_token.py +0 -0
  73. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/models/role.py +0 -0
  74. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/models/user_role.py +0 -0
  75. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/.gitkeep +0 -0
  76. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/AcceptInvite.tsx +0 -0
  77. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/ForgotPassword.tsx +0 -0
  78. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/Profile.tsx +0 -0
  79. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/Register.tsx +0 -0
  80. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/ResetPassword.tsx +0 -0
  81. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/Users/Edit.tsx +0 -0
  82. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/Users/Index.tsx +0 -0
  83. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/Users/Invite.tsx +0 -0
  84. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  85. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/pages/VerifyEmail.tsx +0 -0
  86. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/py.typed +0 -0
  87. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/rate_limit.py +0 -0
  88. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/roles_cache.py +0 -0
  89. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/users/service.py +0 -0
  90. {simple_module_users-0.0.11 → simple_module_users-0.0.13}/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.11
3
+ Version: 0.0.13
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.11
28
- Requires-Dist: simple-module-core==0.0.11
29
- Requires-Dist: simple-module-db==0.0.11
30
- Requires-Dist: simple-module-hosting==0.0.11
31
- Requires-Dist: simple-module-settings==0.0.11
26
+ Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
+ Requires-Dist: simple-module-auth==0.0.13
28
+ Requires-Dist: simple-module-core==0.0.13
29
+ Requires-Dist: simple-module-db==0.0.13
30
+ Requires-Dist: simple-module-hosting==0.0.13
31
+ Requires-Dist: simple-module-settings==0.0.13
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 `simple-module new`.
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
- - `sm-users create-admin` CLI for ad-hoc admin creation.
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 sm-users create-admin --email admin@example.com --password 'change-me'
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 `simple-module new`.
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
- - `sm-users create-admin` CLI for ad-hoc admin creation.
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 sm-users create-admin --email admin@example.com --password 'change-me'
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.11"
3
+ version = "0.0.13"
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.11",
25
- "simple_module_db==0.0.11",
26
- "simple_module_hosting==0.0.11",
27
- "simple_module_settings==0.0.11",
28
- "simple_module_auth==0.0.11",
24
+ "simple_module_core==0.0.13",
25
+ "simple_module_db==0.0.13",
26
+ "simple_module_hosting==0.0.13",
27
+ "simple_module_settings==0.0.13",
28
+ "simple_module_auth==0.0.13",
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 sm-users CLI (users.cli).
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
- """``sm-users --help`` shows the top-level help text and lists create-admin."""
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 ``sm-users`` (see pyproject.toml [project.scripts]).
3
+ Exposed via ``smpy users`` (see pyproject.toml
4
+ [project.entry-points.simple_module_cli.cli_plugins]).
4
5
 
5
- sm-users create-admin --email a@b.test --password sekret [--full-name Me]
6
- sm-users create-admin --email a@b.test --password new --force
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
- yield UserDatabaseWithRoles(session, User)
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
- # The register router is always mounted; its `allow_signup` gate lives
117
- # on a per-request dependency, so toggling the setting at runtime takes
118
- # effect without needing to remount.
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<{ props: Props }>()
23
- .props as unknown as Props;
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 (``sm new --preset minimal``) override
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.