|
| 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. |
0 commit comments