Skip to content

Commit 91c4aa9

Browse files
authored
feat(users): Microsoft (Entra ID) OAuth + DB-settings-driven providers (#189)
* docs(users): spec for Microsoft OAuth + DB-settings-driven providers Microsoft (Entra ID) provider plus a refactor moving all OAuth providers off env vars onto the DB-backed settings UI with live reload, resolving providers at request time so DB-only credentials actually mount. Spec only — no implementation yet. * docs(users): implementation plan for Microsoft OAuth + DB-settings providers * feat(users): DB-backed, grouped OAuth settings + Microsoft fields * feat(users): Microsoft OAuth client + build_client_map * feat(users): add oauth_clients cache slot to UsersState * refactor(users): request-time OAuth dispatcher over hydrated settings * feat(users): hot-reload OAuth providers on SettingsReloaded * refactor(users): extract provider_buttons helper; type reload handler * docs(users): social sign-in setup + env→settings migration * test(users): split OAuth route + reload tests into test_oauth_routes.py (300-line cap) * refactor(users): drop now-unused logger from oauth dispatcher
1 parent db7ec1c commit 91c4aa9

11 files changed

Lines changed: 1346 additions & 129 deletions

File tree

docs/superpowers/plans/2026-06-03-microsoft-oidc.md

Lines changed: 768 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
# Microsoft (Entra ID) OAuth — DB-settings-driven, hot-reloadable providers
2+
3+
**Date:** 2026-06-03
4+
**Status:** Approved (design)
5+
**Module:** `users`
6+
7+
## Summary
8+
9+
Add a first-class `microsoft` OAuth provider (Microsoft Entra ID / Microsoft
10+
accounts) to the `users` module, alongside the existing Google, GitHub, and
11+
generic-OIDC providers. As part of the same change, move **all** OAuth provider
12+
configuration off environment variables onto the DB-backed settings UI
13+
(`/settings/modules → Users`) with **live reload**, and fix a latent
14+
inconsistency in how providers are wired today.
15+
16+
## Background — why this is more than "add one provider"
17+
18+
The OAuth plumbing is already provider-agnostic at the transport layer
19+
(`users/oauth/api.py` mounts a `/login` + `/callback` pair per provider;
20+
find-or-create flows through `UserManager.oauth_callback`). Adding a named
21+
provider is normally a two-branch change in `users/oauth/providers.py`.
22+
23+
The requirement here is that Microsoft be configured through the **admin
24+
settings UI**, not env vars. That collides with a structural detail:
25+
26+
- **Routes mount at app *construction*.** `UsersModule.register_routes`
27+
(`create_app` step 9) calls `register_oauth_routes(api_router,
28+
UsersSettings())`. That fresh `UsersSettings()` only carries values captured
29+
by `env_str()` at import time — DB settings have **not** been hydrated yet
30+
(hydration runs later, in the lifespan, before `on_startup`).
31+
- **Buttons mount at *startup*.** `on_startup` sets
32+
`state.oauth_providers = enabled_provider_names(s)` from the **hydrated**
33+
settings.
34+
35+
Consequence: a provider configured **only via the settings UI** today renders a
36+
login button (startup reads hydrated settings) whose route was **never mounted**
37+
(construction read empty env defaults) → the button 404s. The existing
38+
Google/GitHub/OIDC providers only work because their credentials arrive via env
39+
and are captured at import time. `requires_restart=True` cannot paper over this:
40+
a restart re-runs `register_routes` before hydration again, so DB-only
41+
credentials remain invisible at mount time.
42+
43+
Therefore, to support DB-settings-driven providers at all, the route layer must
44+
resolve providers at **request time** from hydrated settings. We do this for
45+
**all** providers (not just Microsoft) so the system stays consistent and the
46+
latent button-404 bug is fixed everywhere. This mirrors the migration
47+
`background_tasks` already made (its settings stopped reading `SM_BG_TASKS_*`
48+
env vars; values now come from DB hydration).
49+
50+
## Goals
51+
52+
1. `microsoft` provider usable end-to-end (login button → IdP → callback →
53+
find-or-create user → session).
54+
2. All OAuth providers configured via the settings UI, with credentials masked.
55+
3. Provider changes take effect **without a process restart** (live reload via
56+
the existing `SettingsReloaded` event).
57+
4. No new DB tables or migrations.
58+
59+
## Non-goals (YAGNI)
60+
61+
- No token-refresh / offline-access flow.
62+
- No provider logos/icons on buttons (matches current text buttons).
63+
- No PKCE changes (the Microsoft client already sets `response_mode=query`).
64+
- No change to the find-or-create / cookie / redirect semantics in the callback.
65+
66+
## Design
67+
68+
### 1. Settings — `modules/users/users/settings.py`
69+
70+
Add Microsoft fields as plain `Field` (no `env_str`), grouped for the admin UI:
71+
72+
```python
73+
_MS_OAUTH = {"group": "Microsoft OAuth"}
74+
oauth_microsoft_client_id: str = Field(default="", json_schema_extra=_MS_OAUTH)
75+
oauth_microsoft_client_secret: str = Field(default="", json_schema_extra=_MS_OAUTH) # auto-masked
76+
oauth_microsoft_tenant: str = Field(default="common", json_schema_extra=_MS_OAUTH)
77+
```
78+
79+
- Migrate the existing `oauth_google_*`, `oauth_github_*`, `oauth_oidc_*` fields
80+
off `env_str(...)` → plain `Field(default=...)`, adding `group` metadata
81+
(`"Google OAuth"`, `"GitHub OAuth"`, `"OIDC"`). Secret-bearing fields
82+
(`*_client_secret`) auto-mask via `is_secret_field` (matches `secret`).
83+
- **Leave the two token-secret fields (`reset_password_token_secret`,
84+
`verification_token_secret`) on `env_str`** — they are a deliberate bootstrap
85+
path (the `@model_validator` must be satisfiable before any DB-backed setting
86+
can be seeded). Untouched.
87+
- Rewrite the stale comment that claims OAuth secrets are env-only "because
88+
admins can read the DB settings table" — secrets are masked in the UI
89+
(`••••••••`), the same treatment the SMTP password already gets.
90+
- **No `requires_restart`** on any OAuth field: the route layer becomes live.
91+
92+
**Tenant default:** `common` (httpx_oauth default — any work/school *or*
93+
personal Microsoft account). Configurable per deployment; operators lock down
94+
to their org by setting the tenant to their tenant GUID or `organizations`.
95+
Documented as a security note.
96+
97+
### 2. Provider construction — `modules/users/users/oauth/providers.py`
98+
99+
- Add a `microsoft` branch to `build_clients()`:
100+
101+
```python
102+
from httpx_oauth.clients.microsoft import MicrosoftGraphOAuth2
103+
MicrosoftGraphOAuth2(
104+
settings.oauth_microsoft_client_id,
105+
settings.oauth_microsoft_client_secret,
106+
tenant=settings.oauth_microsoft_tenant or "common",
107+
name="microsoft",
108+
)
109+
```
110+
Gated on `client_id and client_secret` being set.
111+
- **Remove `enabled_provider_names()`.** The login-button list is now derived
112+
from the successfully-built client map, so a provider only gets a button if
113+
its client actually constructed (fixes the case where OIDC discovery fails but
114+
a button still shows).
115+
- Add `build_client_map(settings) -> dict[str, OAuthProvider]` (thin wrapper:
116+
`{p.name: p for p in build_clients(settings)}`) for O(1) request-time lookup.
117+
118+
`OAuthProvider` (NamedTuple: `name`, `display_name`, `client`) is unchanged.
119+
120+
### 3. Route dispatcher — `modules/users/users/oauth/api.py`
121+
122+
Replace the N per-provider routers with **one** provider-agnostic pair, mounted
123+
unconditionally:
124+
125+
- `GET /auth/{provider}/login`
126+
- `GET /auth/{provider}/callback` (route name `users_oauth_callback`)
127+
128+
Behaviour:
129+
130+
- Resolve the client at **request time**: `provider_obj =
131+
request.app.state.users.oauth_clients.get(provider)`; if `None``404`.
132+
- Callback URL: `request.url_for("users_oauth_callback", provider=provider)`
133+
(Starlette `url_for` with a path param) — used identically in `/login` (to
134+
pass to the IdP) and `/callback` (token exchange).
135+
- State CSRF: unchanged mechanism — `secrets.token_urlsafe(32)` stashed under
136+
the per-provider session key `oauth_state:{provider}`, compared with
137+
`compare_digest`.
138+
- Everything from `get_access_token``get_id_email``oauth_callback`
139+
cookie → 303 redirect to `login_redirect_url` is **unchanged**.
140+
141+
`register_oauth_routes(api_router)` no longer takes `settings` and always mounts
142+
the single dispatcher.
143+
144+
### 4. Cache lifecycle — `modules/users/users/module.py` + `state.py`
145+
146+
- `UsersState`: add `oauth_clients: dict[str, OAuthProvider] =
147+
field(default_factory=dict)` (default empty so request handlers and tests that
148+
skip `on_startup` see an empty map, not `AttributeError`).
149+
- `register_routes`: mount the dispatcher unconditionally; **drop** the
150+
pre-hydration `settings = UsersSettings()` block (it existed only to feed the
151+
old route builder).
152+
- `on_startup`: build the cache and derive the button list from the **hydrated**
153+
settings `s`:
154+
```python
155+
state.oauth_clients = build_client_map(s)
156+
state.oauth_providers = [
157+
{"name": p.name, "display_name": p.display_name}
158+
for p in state.oauth_clients.values()
159+
]
160+
```
161+
- Override `register_event_handlers(self, bus, app)`: subscribe to
162+
`SettingsReloaded`; when `event.package == "users"`, rebuild `oauth_clients`
163+
and `oauth_providers` from `app.state.users.settings`. → providers can be
164+
added/removed live with no restart. `SettingsReloaded` is imported from
165+
`settings.contracts.events` (plugin→plugin import; `users` already depends on
166+
the `settings` module via `register_module_settings`).
167+
168+
### 5. Frontend — no change
169+
170+
`Login.tsx` already maps `oauth_providers` → outline buttons linking to
171+
`/api/users/auth/{name}/login`. A "Microsoft" button appears automatically once
172+
configured. The login view (`users/auth_local/views.py`) reads
173+
`request.app.state.users.oauth_providers` per request, so a live cache rebuild
174+
is reflected on the next page load.
175+
176+
## Identity mapping & documented caveat
177+
178+
`MicrosoftGraphOAuth2.get_id_email` returns `(profile["id"],
179+
profile["userPrincipalName"])` from Graph `/me`. For tenant members,
180+
`userPrincipalName` equals the email. For **guest/external** accounts the UPN is
181+
not a clean email (e.g. `user_ext.com#EXT#@tenant.onmicrosoft.com`), and that
182+
string becomes the local `account_email`. We use the stock client and **document
183+
this limitation** rather than overriding `get_id_email` (consistent with using
184+
the upstream client for Google/GitHub).
185+
186+
## Data / migrations
187+
188+
None. `OAuthAccount.oauth_name` is already `str(max_length=100)``"microsoft"`
189+
fits with no schema change. Settings overrides persist in the settings module's
190+
existing store table.
191+
192+
## Testing — `modules/users/tests/test_oauth.py` (+ additions)
193+
194+
- `build_clients` / `build_client_map` include `microsoft` when configured;
195+
carry the right `client_id`; the tenant is reflected in the client's authorize
196+
URL; provider skipped when the secret is missing.
197+
- Dispatcher: a configured provider redirects (302) from `/login`; an unknown /
198+
unconfigured `{provider}` returns `404`.
199+
- `SettingsReloaded(package="users")` handler rebuilds the cache: a provider not
200+
present at boot appears after reload; a provider whose credentials are cleared
201+
disappears.
202+
- Update / remove tests that referenced `enabled_provider_names`.
203+
- Existing Google/GitHub `build_clients` and `oauth_callback` find-or-create /
204+
email-association tests continue to pass unchanged.
205+
206+
Network-hitting paths (real token exchange, Graph profile fetch) remain out of
207+
automated coverage, consistent with the existing test file's stated policy;
208+
validated in a manual QA pass against a dev IdP.
209+
210+
## Docs
211+
212+
- `modules/users/README.md`: add a "Social / Microsoft sign-in" section —
213+
configure at **/settings/modules → Users → Microsoft OAuth**; redirect URI is
214+
`<base>/api/users/auth/microsoft/callback`; tenant guidance (`common` vs
215+
tenant GUID / `organizations`); note that the secret is masked in the UI.
216+
- Note the env→DB migration for existing Google/GitHub/OIDC deployments: run
217+
`smpy settings import-from-env` once (maps `SM_USERS_OAUTH_*` → fields).
218+
- Document the UPN-isn't-always-email caveat for guest accounts.
219+
- Release notes: call out that OAuth credentials are no longer read from
220+
`SM_USERS_OAUTH_*` env vars at runtime — use the settings UI or
221+
`import-from-env`.
222+
223+
## Risks
224+
225+
- **Behaviour change for env-configured deployments.** Dropping `env_str` from
226+
Google/GitHub/OIDC means `SM_USERS_OAUTH_*` env vars are no longer read at
227+
runtime. Mitigation: `smpy settings import-from-env` (the same path
228+
`background_tasks` used) + release-notes callout.
229+
- **OIDC discovery timing.** Discovery moves from construction to
230+
`on_startup`/on-reload. Equivalent boot-time cost; failures still degrade
231+
gracefully (`OpenIDConfigurationError` caught → provider dropped from the
232+
map, so no button and a 404 route rather than a boot failure).
233+
- **`url_for` with path param** must produce the exact callback URL registered
234+
with the IdP; covered by the dispatcher test and manual QA.
235+
236+
## Files touched
237+
238+
- `modules/users/users/settings.py` — Microsoft fields; migrate OAuth fields off
239+
`env_str`; `group` metadata; comment rewrite.
240+
- `modules/users/users/oauth/providers.py``microsoft` branch; remove
241+
`enabled_provider_names`; add `build_client_map`.
242+
- `modules/users/users/oauth/api.py` — single request-time dispatcher.
243+
- `modules/users/users/oauth/__init__.py` — exports.
244+
- `modules/users/users/state.py``oauth_clients` field.
245+
- `modules/users/users/module.py` — unconditional mount; `on_startup` cache
246+
build; `register_event_handlers` reload.
247+
- `modules/users/tests/test_oauth.py` — updated + new tests.
248+
- `modules/users/README.md` — provider setup docs.
249+
- Release notes — env→DB migration callout.

modules/users/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ async def profile(user: CurrentUser):
5050
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
5151
- `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
5252

53+
## Social sign-in (Google, GitHub, Microsoft, OIDC)
54+
55+
OAuth providers are configured in the admin UI at **/settings/modules → Users**
56+
(no environment variables). Each provider activates once its client id **and**
57+
secret are set; the secret is masked in the UI. Changes apply live — no restart.
58+
59+
**Microsoft (Entra ID).** Register an app in the Entra admin center and set the
60+
redirect URI to `<base-url>/api/users/auth/microsoft/callback`. Configure under
61+
the **Microsoft OAuth** group:
62+
63+
- `oauth_microsoft_client_id`, `oauth_microsoft_client_secret`
64+
- `oauth_microsoft_tenant``common` (any work/school or personal account,
65+
the default), `organizations` (work/school only), or your tenant GUID to
66+
restrict sign-in to one tenant.
67+
68+
Each provider's callback URL is `<base-url>/api/users/auth/<provider>/callback`
69+
(`google`, `github`, `microsoft`, `oidc`).
70+
71+
> **Note:** for Microsoft *guest/external* accounts the identity email comes
72+
> from the Graph `userPrincipalName`, which may not be a plain email
73+
> (e.g. `user_ext.com#EXT#@tenant.onmicrosoft.com`). For tenant members it is
74+
> the user's email.
75+
76+
### Migrating from `SM_USERS_OAUTH_*` env vars
77+
78+
Earlier versions read provider credentials from `SM_USERS_OAUTH_*` environment
79+
variables. These are no longer read at runtime. Migrate existing values into the
80+
settings store once with:
81+
82+
uv run smpy settings import-from-env
83+
5384
## License
5485

5586
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).

0 commit comments

Comments
 (0)