From 5d8836d44da86e1392342de2b6dee13fd39a2b15 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 17:04:17 +0100 Subject: [PATCH 1/6] docs: entra-tenant-upn-trust design (#873) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- ...026-11-11-entra-tenant-upn-trust-design.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md diff --git a/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md b/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md new file mode 100644 index 00000000..2f3e2278 --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md @@ -0,0 +1,80 @@ +# Trusted single-tenant UPN policy for Microsoft sign-in (#873) + +## Goal & scope + +With the `users` provider, Microsoft (Entra) sign-in can never prove an email +(`get_identity` always returns `email_verified=False`), so on a site with +self-signup closed nobody can sign in with Microsoft: a verified local account +or pending invite with the same email is refused (`OAUTH_USER_ALREADY_EXISTS`), +and a new account is refused (`OAUTH_SIGNUP_DISABLED`). + +In scope: an opt-in setting that, for a single pinned tenant, treats a member +account's `userPrincipalName` as a verified email, so it links to an invited or +verified local account and new accounts start verified. + +Out of scope (separate issues): provisioning independent of `allow_signup` +(#874), OIDC-based Microsoft provider with validated `oid`/`tid` claims (#876), +storing the IdP profile (#877), trusted email domains (#878), a dev discovery +override (#879). + +## Approach + +Add `oauth_microsoft_trust_tenant_upn: bool = False` to `UsersSettings` +(group "Microsoft OAuth"). `build_clients` resolves the policy once: it is +**effective** only when the setting is on and `oauth_microsoft_tenant` parses +as a UUID. `OAuthProvider` gains a `trust_email: bool = False` field (the +NamedTuple stays positional-compatible), and Microsoft's `get_identity` returns +`(graph id, UPN, True)` when `trust_email` is set, the id and UPN are non-empty, +and the UPN has no `#EXT#` (case-insensitive). Everything else stays `False`. + +Rejected: +- Trust `mail` instead of UPN: `mail` is user-/admin-editable and not + domain-verified; the issue explicitly says never `mail`. +- Wait for #876 (validated `tid` claim): the Graph flow is already pinned to the + tenant by the authorize/token endpoint (`login.microsoftonline.com/`), + so a token can only come from that tenant; #876 can add a `tid` check later + without changing this setting. + +## Design + +- `users/core/settings.py`: the field, plus a `model_validator` that logs + `users.oauth.microsoft_trust_ignored` (WARNING) when the setting is on and the + tenant is `common`/`organizations`/`consumers`/any non-UUID. Not an error: a + settings edit in the admin UI must not fail, and the safe behaviour (no trust) + is what applies. +- `users/oauth/providers.py`: `OAuthProvider.trust_email`; helper + `_is_tenant_guid(tenant)`; Microsoft branch of `get_identity` as above. +- No change to `api.py`: `email_verified` already drives `associate_by_email` + and `is_verified_by_default`, the `_unproven_account` guard (unverified, + un-invited local account → `OAUTH_ACCOUNT_NOT_VERIFIED`) and the + `allow_signup` gate stay as they are. +- Docs: `modules/users/README.md`, `docs/modules/users.md` (settings table + + provider table + a short "Signing in pre-approved staff" note), CHANGELOG. + +## Decisions made + +- Setting name from the issue (`oauth_microsoft_trust_tenant_upn`), default off. +- UUID check via `uuid.UUID(...)`, so any GUID form is accepted; verified-domain + names (`contoso.onmicrosoft.com`) as tenant are **not** trusted, since the + issue scopes trust to a GUID. +- `#EXT#` match is case-insensitive; an empty id or UPN is never trusted. +- Warning at settings validation (boot and every settings reload), not a + doctor code. + +## Testing + +- Unit (`tests/oauth/flow/test_users_oauth_provider_identity.py`): member UPN + trusted with a GUID tenant; guest `#EXT#` UPN, empty id/UPN, setting off, + and `common`/`organizations`/domain tenant all stay unverified. +- Settings: warning logged for non-GUID tenant with trust on; silent otherwise. +- Flow (`test_users_oauth_*` style, fake Microsoft client): with the policy on + and `allow_signup=False`, an invited account and a verified local account + both sign in and get linked; an unverified, un-invited local account is still + refused; guest UPN is still refused. +- No browser QA (backend-only; no Entra tenant available locally). + +## Risks + +- A UPN is not always the user's mail address (UPN `j.doe@corp.com` vs invite + sent to `jane@corp.com`): the invite then doesn't match and the user is + refused as before. Documented; #878 (trusted domains) and #877 cover more. From 3868c01ef4e7d37c7a7ba067995aa001313cb4b5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 17:04:44 +0100 Subject: [PATCH 2/6] docs: entra-tenant-upn-trust plan (#873) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../2026-11-11-entra-tenant-upn-trust.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-11-entra-tenant-upn-trust.md diff --git a/docs/superpowers/plans/2026-11-11-entra-tenant-upn-trust.md b/docs/superpowers/plans/2026-11-11-entra-tenant-upn-trust.md new file mode 100644 index 00000000..c927acde --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-entra-tenant-upn-trust.md @@ -0,0 +1,22 @@ +# Plan: trusted single-tenant UPN policy (#873) + +Design: `docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md`. +Tasks run in order (each touches the previous one's files). + +1. **Identity rule** — `modules/users/users/oauth/providers.py`. + Test first in `tests/oauth/flow/test_users_oauth_provider_identity.py`: + `OAuthProvider(..., trust_email=True)` returns verified for a member UPN; + `#EXT#` (any case), empty id, empty UPN, `trust_email=False` stay unverified. + Add `trust_email: bool = False` to `OAuthProvider`, use it in the Microsoft branch. + Done: new tests pass, existing identity tests unchanged. +2. **Setting + policy resolution** — `users/core/settings.py`, `providers.build_clients`. + Tests: `build_clients` sets `trust_email` only for setting on + GUID tenant + (`common`, `organizations`, `consumers`, `contoso.onmicrosoft.com` → False); + a WARNING `users.oauth.microsoft_trust_ignored` is logged for trust on + non-GUID, + none otherwise. Done: tests pass, settings.py under 300 lines. +3. **Callback flow** — new `tests/oauth/flow/test_users_oauth_tenant_upn_trust.py`, + following `test_users_oauth_signup_gate.py`/`..._unverified_link.py` fakes: + `allow_signup=False` + policy on → invited and verified local accounts sign in and + link; unverified un-invited account → `OAUTH_ACCOUNT_NOT_VERIFIED`; guest UPN → refused. +4. **Docs** — users README, `docs/modules/users.md`, `CHANGELOG.md` `[Unreleased]`. +5. **Full check** — `make lint`, `make test-py`, `make test-js`. From 33515453ca8cd5d0cafcc557e9f5b603b25937a8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 17:09:56 +0100 Subject: [PATCH 3/6] feat(users): trusted single-tenant UPN policy for Microsoft sign-in (#873) oauth_microsoft_trust_tenant_upn (off by default) treats a member account's userPrincipalName as a verified email when oauth_microsoft_tenant is a GUID, so invited and verified local accounts link on first Microsoft sign-in. Guest (#EXT#) UPNs and empty identities stay unverified; a non-GUID tenant logs users.oauth.microsoft_trust_ignored and keeps the old behaviour. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/users/tests/_users_oauth_support.py | 14 +-- .../test_users_oauth_provider_identity.py | 32 +++++++ .../flow/test_users_oauth_tenant_upn_trust.py | 89 +++++++++++++++++++ ...test_users_oauth_microsoft_trust_policy.py | 57 ++++++++++++ modules/users/users/core/settings.py | 31 +++++++ modules/users/users/oauth/providers.py | 13 ++- 6 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 modules/users/tests/oauth/flow/test_users_oauth_tenant_upn_trust.py create mode 100644 modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py diff --git a/modules/users/tests/_users_oauth_support.py b/modules/users/tests/_users_oauth_support.py index 333d132e..67be75a6 100644 --- a/modules/users/tests/_users_oauth_support.py +++ b/modules/users/tests/_users_oauth_support.py @@ -29,11 +29,13 @@ async def get_profile(self, access_token: str) -> dict: return {"sub": self._account_id, "email": self._account_email, "email_verified": True} -def install_fake_provider(app, account_id: str, account_email: str) -> None: +def install_fake_provider( + app, account_id: str, account_email: str, provider: str = "oidc", **fields +) -> None: from users.oauth import OAuthProvider - app.state.users.oauth_clients["oidc"] = OAuthProvider( - "oidc", "OIDC", _FakeOAuthClient(account_id, account_email) + app.state.users.oauth_clients[provider] = OAuthProvider( + provider, provider, _FakeOAuthClient(account_id, account_email), **fields ) @@ -71,13 +73,13 @@ async def seed_password_user(app, email: str, **fields) -> uuid.UUID: return user_id -async def oauth_callback(client: httpx.AsyncClient) -> httpx.Response: +async def oauth_callback(client: httpx.AsyncClient, provider: str = "oidc") -> httpx.Response: """Drive /login then /callback; return the callback response.""" - login = await client.get("/api/users/auth/oidc/login", follow_redirects=False) + login = await client.get(f"/api/users/auth/{provider}/login", follow_redirects=False) assert login.status_code == 302 state = parse_qs(urlparse(login.headers["location"]).query)["state"][0] return await client.get( - f"/api/users/auth/oidc/callback?code=abc&state={state}", + f"/api/users/auth/{provider}/callback?code=abc&state={state}", follow_redirects=False, ) diff --git a/modules/users/tests/oauth/flow/test_users_oauth_provider_identity.py b/modules/users/tests/oauth/flow/test_users_oauth_provider_identity.py index 755f24ca..446a9e68 100644 --- a/modules/users/tests/oauth/flow/test_users_oauth_provider_identity.py +++ b/modules/users/tests/oauth/flow/test_users_oauth_provider_identity.py @@ -63,3 +63,35 @@ async def test_github_unreadable_emails_is_not_proof(): async def test_microsoft_email_is_never_proof(): provider = OAuthProvider("microsoft", "Microsoft", _Microsoft()) assert await provider.get_identity("t") == ("ms-1", "me@example.com", False) + + +class _MicrosoftUpn: + def __init__(self, account_id, upn): + self.account_id, self.upn = account_id, upn + + async def get_id_email(self, token): + return self.account_id, self.upn + + +async def test_microsoft_trusted_tenant_member_upn_is_proof(): + """#873: a member UPN of the pinned tenant is on a domain that tenant verified.""" + provider = OAuthProvider( + "microsoft", "Microsoft", _MicrosoftUpn("ms-1", "jane@corp.com"), trust_email=True + ) + assert await provider.get_identity("t") == ("ms-1", "jane@corp.com", True) + + +@pytest.mark.parametrize( + "account_id, upn", + [ + ("ms-1", "jane_ext.com#EXT#@corp.onmicrosoft.com"), + ("ms-1", "jane_ext.com#ext#@corp.onmicrosoft.com"), + ("", "jane@corp.com"), + ("ms-1", ""), + ], +) +async def test_microsoft_trust_excludes_guests_and_empty_identities(account_id, upn): + provider = OAuthProvider( + "microsoft", "Microsoft", _MicrosoftUpn(account_id, upn), trust_email=True + ) + assert (await provider.get_identity("t"))[2] is False diff --git a/modules/users/tests/oauth/flow/test_users_oauth_tenant_upn_trust.py b/modules/users/tests/oauth/flow/test_users_oauth_tenant_upn_trust.py new file mode 100644 index 00000000..e42318dd --- /dev/null +++ b/modules/users/tests/oauth/flow/test_users_oauth_tenant_upn_trust.py @@ -0,0 +1,89 @@ +"""Microsoft sign-in for pre-approved staff with signup closed (#873). + +With ``oauth_microsoft_trust_tenant_upn`` effective, a member UPN is proof of +the email, so it links to an invited or verified local account. The guards for +unproven accounts and guests stay. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from _users_oauth_support import ( + install_fake_provider, + oauth_callback, + seed_password_user, + set_allow_signup, +) +from sqlalchemy import select +from users.models import OAuthAccount + +_EMAIL = "jane@corp.com" + + +def _microsoft(app, upn: str = _EMAIL, *, trust: bool = True) -> None: + install_fake_provider(app, "ms-jane", upn, provider="microsoft", trust_email=trust) + + +@pytest.mark.anyio +async def test_invited_account_signs_in_with_signup_closed(users_app, anon_client): + set_allow_signup(users_app, False) + user_id = await seed_password_user( + users_app, _EMAIL, is_verified=False, invited_at=datetime.now(UTC) + ) + _microsoft(users_app) + + assert (await oauth_callback(anon_client, "microsoft")).status_code == 303 + async with users_app.state.sm.db.session_factory() as session: + links = (await session.execute(select(OAuthAccount.user_id))).scalars().all() + assert links == [user_id] + + +@pytest.mark.anyio +async def test_verified_account_links_instead_of_already_exists(users_app, anon_client): + set_allow_signup(users_app, False) + await seed_password_user(users_app, _EMAIL, is_verified=True) + _microsoft(users_app) + + assert (await oauth_callback(anon_client, "microsoft")).status_code == 303 + + +@pytest.mark.anyio +async def test_without_trust_a_verified_account_is_still_refused(users_app, anon_client): + set_allow_signup(users_app, False) + await seed_password_user(users_app, _EMAIL, is_verified=True) + _microsoft(users_app, trust=False) + + resp = await oauth_callback(anon_client, "microsoft") + assert resp.json()["detail"] == "OAUTH_USER_ALREADY_EXISTS" + + +@pytest.mark.anyio +async def test_unproven_local_account_is_still_refused(users_app, anon_client): + await seed_password_user(users_app, _EMAIL, is_verified=False) + _microsoft(users_app) + + resp = await oauth_callback(anon_client, "microsoft") + assert resp.status_code == 403 + assert resp.json()["detail"] == "OAUTH_ACCOUNT_NOT_VERIFIED" + + +@pytest.mark.anyio +async def test_guest_upn_does_not_link(users_app, anon_client): + guest = "jane_corp.com#EXT#@other.onmicrosoft.com" + await seed_password_user(users_app, guest, is_verified=True) + _microsoft(users_app, guest) + + resp = await oauth_callback(anon_client, "microsoft") + assert resp.json()["detail"] == "OAUTH_USER_ALREADY_EXISTS" + + +@pytest.mark.anyio +async def test_signup_gate_still_applies_to_unknown_staff(users_app, anon_client): + set_allow_signup(users_app, False) + _microsoft(users_app) + + resp = await oauth_callback(anon_client, "microsoft") + assert resp.status_code == 403 + assert resp.json()["detail"] == "OAUTH_SIGNUP_DISABLED" diff --git a/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py b/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py new file mode 100644 index 00000000..aba1a5fe --- /dev/null +++ b/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py @@ -0,0 +1,57 @@ +"""The Microsoft UPN trust policy applies only to a single pinned tenant (#873).""" + +import logging + +import pytest +from users.core.settings import UsersSettings +from users.oauth.providers import build_client_map + +_GUID = "6f1c2a3b-4d5e-4f60-8a7b-9c0d1e2f3a4b" + + +def _settings(tenant: str, trust: bool) -> UsersSettings: + return UsersSettings( + oauth_microsoft_client_id="ms-id", + oauth_microsoft_client_secret="ms-secret", + oauth_microsoft_tenant=tenant, + oauth_microsoft_trust_tenant_upn=trust, + ) + + +def test_trust_is_off_by_default(): + assert UsersSettings().oauth_microsoft_trust_tenant_upn is False + settings = UsersSettings( + oauth_microsoft_client_id="ms-id", + oauth_microsoft_client_secret="ms-secret", + oauth_microsoft_tenant=_GUID, + ) + assert build_client_map(settings)["microsoft"].trust_email is False + + +def test_trust_applies_to_a_tenant_guid(): + assert build_client_map(_settings(_GUID, True))["microsoft"].trust_email is True + assert build_client_map(_settings(_GUID.upper(), True))["microsoft"].trust_email is True + + +@pytest.mark.parametrize( + "tenant", ["common", "organizations", "consumers", "corp.onmicrosoft.com", ""] +) +def test_trust_is_ignored_without_a_single_tenant(tenant, caplog): + with caplog.at_level(logging.WARNING, logger="users.settings"): + settings = _settings(tenant, True) + assert build_client_map(settings)["microsoft"].trust_email is False + assert "users.oauth.microsoft_trust_ignored" in caplog.text + + +def test_no_warning_when_trust_is_effective_or_off(caplog): + with caplog.at_level(logging.WARNING, logger="users.settings"): + _settings(_GUID, True) + _settings("common", False) + assert "microsoft_trust_ignored" not in caplog.text + + +def test_other_providers_never_carry_trust(): + settings = _settings(_GUID, True).model_copy( + update={"oauth_google_client_id": "g", "oauth_google_client_secret": "g"} + ) + assert build_client_map(settings)["google"].trust_email is False diff --git a/modules/users/users/core/settings.py b/modules/users/users/core/settings.py index 36695abd..8e11347d 100644 --- a/modules/users/users/core/settings.py +++ b/modules/users/users/core/settings.py @@ -12,6 +12,7 @@ import logging import os +import uuid from typing import Literal from pydantic import Field, field_validator, model_validator @@ -37,6 +38,15 @@ DEMO_SETTINGS_GROUP = "Demo account" +def is_tenant_guid(tenant: str) -> bool: + """Whether a Microsoft ``tenant`` pins one directory (a GUID), not ``common`` & co.""" + try: + uuid.UUID(tenant) + except ValueError: + return False + return True + + class UsersSettings(DbBackedSettings): """Local user management configuration.""" @@ -205,6 +215,27 @@ def _lowercase_samesite(cls, value: object) -> object: oauth_microsoft_tenant: str = Field( default="common", json_schema_extra={"group": "Microsoft OAuth"} ) + # Treat a member account's userPrincipalName as a verified email, so it + # links to an invited or verified local account (#873). Only effective when + # the tenant is a GUID: that tenant verified the UPN's domain. Guest UPNs + # (``#EXT#``) stay unverified. + oauth_microsoft_trust_tenant_upn: bool = Field( + default=False, json_schema_extra={"group": "Microsoft OAuth"} + ) + + @model_validator(mode="after") + def _warn_on_ignored_microsoft_trust(self) -> UsersSettings: + """The UPN trust policy is inert unless one tenant is pinned by GUID.""" + if self.oauth_microsoft_trust_tenant_upn and not is_tenant_guid( + self.oauth_microsoft_tenant + ): + logger.warning( + "users.oauth.microsoft_trust_ignored — oauth_microsoft_trust_tenant_upn is on " + "but oauth_microsoft_tenant=%r is not a tenant GUID; Microsoft emails stay " + "unverified. Set the tenant to your directory's GUID to trust member UPNs.", + self.oauth_microsoft_tenant, + ) + return self @model_validator(mode="after") def _warn_on_writable_admin_demo(self) -> UsersSettings: diff --git a/modules/users/users/oauth/providers.py b/modules/users/users/oauth/providers.py index 43de1e7c..c48fe881 100644 --- a/modules/users/users/oauth/providers.py +++ b/modules/users/users/oauth/providers.py @@ -14,6 +14,8 @@ import logging from typing import TYPE_CHECKING, Any, NamedTuple +from users.core.settings import is_tenant_guid + if TYPE_CHECKING: from httpx_oauth.oauth2 import BaseOAuth2 @@ -28,6 +30,8 @@ class OAuthProvider(NamedTuple): name: str display_name: str client: BaseOAuth2 + # Microsoft only: the operator trusts member UPNs of the pinned tenant (#873). + trust_email: bool = False async def get_identity(self, token: str) -> tuple[str, str | None, bool]: """Return subject, email, and proof that the provider verified that email. @@ -46,8 +50,11 @@ async def get_identity(self, token: str) -> tuple[str, str | None, bool]: return account_id, None, False if self.name == "github": return account_id, email, await _github_verified(self.client, token, email) - # Microsoft (Entra) email claims are tenant-controlled and unverified ("nOAuth"). - return account_id, email, False + # Microsoft (Entra) email claims are tenant-controlled and unverified + # ("nOAuth"), unless the operator pinned one tenant and trusts its member + # UPNs. A guest's UPN (``#EXT#``) belongs to another directory. + trusted = self.trust_email and bool(account_id and email) and "#EXT#" not in email.upper() + return account_id, email, trusted def _google_identity(profile: dict[str, Any]) -> tuple[str, str | None, bool]: @@ -120,6 +127,8 @@ def build_clients(settings: UsersSettings) -> list[OAuthProvider]: tenant=settings.oauth_microsoft_tenant or "common", name="microsoft", ), + trust_email=settings.oauth_microsoft_trust_tenant_upn + and is_tenant_guid(settings.oauth_microsoft_tenant), ) ) From f7ad6a174d7d41a919b7075bac2be50cc6bbf7a4 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 17:10:18 +0100 Subject: [PATCH 4/6] docs(users): Microsoft tenant UPN trust policy (#873) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 4 ++++ docs/modules/users.md | 12 ++++++++++++ modules/users/README.md | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05545e87..bdf8e1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Added + +- `users`: `oauth_microsoft_trust_tenant_upn` (off by default) lets a site that pins `oauth_microsoft_tenant` to one tenant GUID sign in pre-approved staff with Microsoft while self-signup stays closed. A member account's `userPrincipalName` then counts as a verified email, so the first Microsoft sign-in links to an invited or verified local account with that address instead of failing with `OAUTH_USER_ALREADY_EXISTS`. Guest UPNs (`#EXT#`) and empty identities stay unverified, the `mail` attribute is never used, and with `common`, `organizations`, `consumers` or a domain-name tenant the setting has no effect and logs `users.oauth.microsoft_trust_ignored` (#873). + ## [0.0.45] — 2026-11-11 ### Fixed diff --git a/docs/modules/users.md b/docs/modules/users.md index c8299678..d7d88b4a 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -224,6 +224,7 @@ Everything else is DB-backed (initial values are pydantic defaults; edit under U | `oauth_google_client_id` / `oauth_google_client_secret` | `""` — Google OAuth | | `oauth_github_client_id` / `oauth_github_client_secret` | `""` — GitHub OAuth | | `oauth_microsoft_client_id` / `oauth_microsoft_client_secret` / `oauth_microsoft_tenant` | `""` / `""` / `"common"` — Microsoft (Entra ID) | +| `oauth_microsoft_trust_tenant_upn` | `False` — treat a member's `userPrincipalName` as a verified email; only with a tenant GUID (see [Signing in pre-approved staff with Microsoft](#signing-in-pre-approved-staff-with-microsoft)) | | `oauth_oidc_client_id` / `oauth_oidc_client_secret` / `oauth_oidc_discovery_url` / `oauth_oidc_display_name` | `""` / `""` / `""` / `"OIDC"` — generic OIDC | ## Permissions @@ -530,6 +531,17 @@ Built-in provider keys (the `{provider}` URL segment): A single dispatcher pair (`/api/users/auth/{provider}/login` + `/callback`) serves every provider; the client is resolved per request from the cache. The `/callback` returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through `UserManager.oauth_callback` (`associate_by_email=True`, `is_verified_by_default=True`); state CSRF uses the signed session cookie. Linked accounts are stored in `users_oauth_account`. A **newly provisioned** OAuth account is marked external (see below); an existing password account linked by email keeps its password and is left unchanged. +### Signing in pre-approved staff with Microsoft + +Microsoft email claims are tenant-controlled, so by default a Microsoft sign-in never proves an email ("nOAuth"). That keeps `common`/`organizations` safe, but it also means that with self-signup closed nobody can sign in with Microsoft: a verified local account or pending invite with the same address is refused with `OAUTH_USER_ALREADY_EXISTS`. + +For a site that admits one Entra directory, set `oauth_microsoft_tenant` to that tenant's GUID and turn on `oauth_microsoft_trust_tenant_upn`. A **member** account's `userPrincipalName` is on a domain the tenant verified, so it then counts as a verified email: it links to an invited or verified local account with that address, and an account it creates starts verified. The account id stays the Graph `id`; the `mail` attribute is never used. + +- Guest accounts (a UPN containing `#EXT#`) and an empty id or UPN stay unverified. +- With `common`, `organizations`, `consumers` or a domain-name tenant the setting has no effect, and the settings load logs a `users.oauth.microsoft_trust_ignored` warning. +- An unverified, un-invited local account with the same address is still refused (`OAUTH_ACCOUNT_NOT_VERIFIED`), and `allow_signup=False` still refuses a Microsoft user who has no account. +- A user whose UPN differs from the address they were invited with does not match the invite; invite them by their UPN. + ## Importing users from another system `users` stores passwords through fastapi-users' default `PasswordHelper`, so you can import `hashed_password` values from another system and users keep their existing passwords (no reset needed). Accepted formats: diff --git a/modules/users/README.md b/modules/users/README.md index 53f5e618..e88934e5 100644 --- a/modules/users/README.md +++ b/modules/users/README.md @@ -93,6 +93,10 @@ the **Microsoft OAuth** group: - `oauth_microsoft_tenant` — `common` (any work/school or personal account, the default), `organizations` (work/school only), or your tenant GUID to restrict sign-in to one tenant. +- `oauth_microsoft_trust_tenant_upn` — off by default. With a tenant GUID, + a member account's `userPrincipalName` counts as a verified email, so it + links to an invited or verified local account on first sign-in. Guests + (`#EXT#`) stay unverified; with any other tenant value it has no effect. Each provider's callback URL is `/api/users/auth//callback` (`google`, `github`, `microsoft`, `oidc`). From fb6b9584dae86a383f5cc7b6446575e91a9ae5e2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 17:14:45 +0100 Subject: [PATCH 5/6] fix: address code review findings (round 1, pass 1) Exclude Microsoft's consumers tenant GUID and non-canonical GUID spellings from the trust policy; document that the policy trusts the tenant's admins; correct the users docs on associate_by_email/is_verified_by_default. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 2 +- docs/modules/users.md | 5 +++-- .../2026-11-11-entra-tenant-upn-trust-design.md | 5 +++-- modules/users/README.md | 2 ++ .../test_users_oauth_microsoft_trust_policy.py | 14 +++++++++++++- modules/users/users/core/settings.py | 14 +++++++++++--- modules/users/users/oauth/providers.py | 4 +++- 7 files changed, 36 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf8e1b7..d43376b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to this project are documented in this file. The format is b ### Added -- `users`: `oauth_microsoft_trust_tenant_upn` (off by default) lets a site that pins `oauth_microsoft_tenant` to one tenant GUID sign in pre-approved staff with Microsoft while self-signup stays closed. A member account's `userPrincipalName` then counts as a verified email, so the first Microsoft sign-in links to an invited or verified local account with that address instead of failing with `OAUTH_USER_ALREADY_EXISTS`. Guest UPNs (`#EXT#`) and empty identities stay unverified, the `mail` attribute is never used, and with `common`, `organizations`, `consumers` or a domain-name tenant the setting has no effect and logs `users.oauth.microsoft_trust_ignored` (#873). +- `users`: `oauth_microsoft_trust_tenant_upn` (off by default) lets a site that pins `oauth_microsoft_tenant` to one tenant GUID sign in pre-approved staff with Microsoft while self-signup stays closed. A member account's `userPrincipalName` then counts as a verified email, so the first Microsoft sign-in links to an invited or verified local account with that address instead of failing with `OAUTH_USER_ALREADY_EXISTS`. Guest UPNs (`#EXT#`) and empty identities stay unverified, the `mail` attribute is never used, and with `common`, `organizations`, `consumers` or a domain-name tenant the setting has no effect and logs `users.oauth.microsoft_trust_ignored`. Turning it on trusts the tenant's admins, who can set a member's UPN to any address in the tenant's domains (#873). ## [0.0.45] — 2026-11-11 diff --git a/docs/modules/users.md b/docs/modules/users.md index d7d88b4a..a87c898c 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -529,7 +529,7 @@ Built-in provider keys (the `{provider}` URL segment): | Microsoft (Entra ID) | `microsoft` | `oauth_microsoft_tenant`: `"common"` (any account), `"organizations"` (work/school), or a tenant GUID | | Generic OIDC | `oidc` | any provider exposing a discovery URL (Keycloak, Authentik, Auth0, Zitadel, …); discovery failure logs + disables rather than breaking boot | -A single dispatcher pair (`/api/users/auth/{provider}/login` + `/callback`) serves every provider; the client is resolved per request from the cache. The `/callback` returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through `UserManager.oauth_callback` (`associate_by_email=True`, `is_verified_by_default=True`); state CSRF uses the signed session cookie. Linked accounts are stored in `users_oauth_account`. A **newly provisioned** OAuth account is marked external (see below); an existing password account linked by email keeps its password and is left unchanged. +A single dispatcher pair (`/api/users/auth/{provider}/login` + `/callback`) serves every provider; the client is resolved per request from the cache. The `/callback` returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through `UserManager.oauth_callback` (`associate_by_email` and `is_verified_by_default` both follow whether the provider proved the email; Microsoft never does unless the opt-in below is on); state CSRF uses the signed session cookie. Linked accounts are stored in `users_oauth_account`. A **newly provisioned** OAuth account is marked external (see below); an existing password account linked by email keeps its password and is left unchanged. ### Signing in pre-approved staff with Microsoft @@ -538,9 +538,10 @@ Microsoft email claims are tenant-controlled, so by default a Microsoft sign-in For a site that admits one Entra directory, set `oauth_microsoft_tenant` to that tenant's GUID and turn on `oauth_microsoft_trust_tenant_upn`. A **member** account's `userPrincipalName` is on a domain the tenant verified, so it then counts as a verified email: it links to an invited or verified local account with that address, and an account it creates starts verified. The account id stays the Graph `id`; the `mail` attribute is never used. - Guest accounts (a UPN containing `#EXT#`) and an empty id or UPN stay unverified. -- With `common`, `organizations`, `consumers` or a domain-name tenant the setting has no effect, and the settings load logs a `users.oauth.microsoft_trust_ignored` warning. +- With `common`, `organizations`, `consumers` (or its fixed GUID) or a domain-name tenant the setting has no effect, and the settings load logs a `users.oauth.microsoft_trust_ignored` warning. - An unverified, un-invited local account with the same address is still refused (`OAUTH_ACCOUNT_NOT_VERIFIED`), and `allow_signup=False` still refuses a Microsoft user who has no account. - A user whose UPN differs from the address they were invited with does not match the invite; invite them by their UPN. +- The policy trusts whoever administers the tenant: an Entra admin who sets a member's UPN to an address that matches a verified local account (an administrator's, say) signs that member in as that account. Turn it on only for a directory whose admins you already trust with your users' identities. ## Importing users from another system diff --git a/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md b/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md index 2f3e2278..576f3e29 100644 --- a/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md +++ b/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md @@ -42,8 +42,9 @@ Rejected: tenant is `common`/`organizations`/`consumers`/any non-UUID. Not an error: a settings edit in the admin UI must not fail, and the safe behaviour (no trust) is what applies. -- `users/oauth/providers.py`: `OAuthProvider.trust_email`; helper - `_is_tenant_guid(tenant)`; Microsoft branch of `get_identity` as above. +- `users/oauth/providers.py`: `OAuthProvider.trust_email`; the helper + `is_tenant_guid(tenant)` lives in `core/settings.py` (the settings validator and + `build_clients` both use it); Microsoft branch of `get_identity` as above. - No change to `api.py`: `email_verified` already drives `associate_by_email` and `is_verified_by_default`, the `_unproven_account` guard (unverified, un-invited local account → `OAUTH_ACCOUNT_NOT_VERIFIED`) and the diff --git a/modules/users/README.md b/modules/users/README.md index e88934e5..8941d8e9 100644 --- a/modules/users/README.md +++ b/modules/users/README.md @@ -97,6 +97,8 @@ the **Microsoft OAuth** group: a member account's `userPrincipalName` counts as a verified email, so it links to an invited or verified local account on first sign-in. Guests (`#EXT#`) stay unverified; with any other tenant value it has no effect. + It trusts the tenant's admins: whoever can set a member's UPN can sign in + as the local account with that address. Each provider's callback URL is `/api/users/auth//callback` (`google`, `github`, `microsoft`, `oidc`). diff --git a/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py b/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py index aba1a5fe..8f383c2c 100644 --- a/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py +++ b/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py @@ -34,7 +34,19 @@ def test_trust_applies_to_a_tenant_guid(): @pytest.mark.parametrize( - "tenant", ["common", "organizations", "consumers", "corp.onmicrosoft.com", ""] + "tenant", + [ + "common", + "organizations", + "consumers", + "corp.onmicrosoft.com", + "", + # Microsoft's personal-account tenant: a GUID, but not one directory. + "9188040d-6c67-4c5b-b112-36a304b66dad", + # Non-canonical GUID spellings Entra does not accept as a tenant segment. + "{6f1c2a3b-4d5e-4f60-8a7b-9c0d1e2f3a4b}", + "6f1c2a3b4d5e4f608a7b9c0d1e2f3a4b", + ], ) def test_trust_is_ignored_without_a_single_tenant(tenant, caplog): with caplog.at_level(logging.WARNING, logger="users.settings"): diff --git a/modules/users/users/core/settings.py b/modules/users/users/core/settings.py index 8e11347d..4e5ddf69 100644 --- a/modules/users/users/core/settings.py +++ b/modules/users/users/core/settings.py @@ -38,13 +38,21 @@ DEMO_SETTINGS_GROUP = "Demo account" +# Microsoft's fixed "consumers" tenant (personal accounts): a GUID, but not one directory. +_MICROSOFT_CONSUMERS_TENANT = uuid.UUID("9188040d-6c67-4c5b-b112-36a304b66dad") + + def is_tenant_guid(tenant: str) -> bool: - """Whether a Microsoft ``tenant`` pins one directory (a GUID), not ``common`` & co.""" + """Whether a Microsoft ``tenant`` pins one work/school directory (a canonical GUID). + + ``common`` & co, non-canonical spellings and the personal-account ``consumers`` + tenant GUID do not qualify. + """ try: - uuid.UUID(tenant) + parsed = uuid.UUID(tenant) except ValueError: return False - return True + return str(parsed) == tenant.lower() and parsed != _MICROSOFT_CONSUMERS_TENANT class UsersSettings(DbBackedSettings): diff --git a/modules/users/users/oauth/providers.py b/modules/users/users/oauth/providers.py index c48fe881..f4f21197 100644 --- a/modules/users/users/oauth/providers.py +++ b/modules/users/users/oauth/providers.py @@ -53,7 +53,9 @@ async def get_identity(self, token: str) -> tuple[str, str | None, bool]: # Microsoft (Entra) email claims are tenant-controlled and unverified # ("nOAuth"), unless the operator pinned one tenant and trusts its member # UPNs. A guest's UPN (``#EXT#``) belongs to another directory. - trusted = self.trust_email and bool(account_id and email) and "#EXT#" not in email.upper() + trusted = ( + self.trust_email and bool(account_id) and bool(email) and "#EXT#" not in email.upper() + ) return account_id, email, trusted From ca3250742164219164a976f3e47b25d2b7b6c940 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 17:19:15 +0100 Subject: [PATCH 6/6] fix: address code review findings (round 1, pass 2) Warning text and design doc match the canonical, non-consumers GUID rule. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../specs/2026-11-11-entra-tenant-upn-trust-design.md | 10 ++++++---- .../test_users_oauth_microsoft_trust_policy.py | 2 +- modules/users/users/core/settings.py | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md b/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md index 576f3e29..d2b8257a 100644 --- a/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md +++ b/docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md @@ -21,8 +21,8 @@ override (#879). Add `oauth_microsoft_trust_tenant_upn: bool = False` to `UsersSettings` (group "Microsoft OAuth"). `build_clients` resolves the policy once: it is -**effective** only when the setting is on and `oauth_microsoft_tenant` parses -as a UUID. `OAuthProvider` gains a `trust_email: bool = False` field (the +**effective** only when the setting is on and `oauth_microsoft_tenant` is a +canonical work/school tenant GUID (`is_tenant_guid`). `OAuthProvider` gains a `trust_email: bool = False` field (the NamedTuple stays positional-compatible), and Microsoft's `get_identity` returns `(graph id, UPN, True)` when `trust_email` is set, the id and UPN are non-empty, and the UPN has no `#EXT#` (case-insensitive). Everything else stays `False`. @@ -39,7 +39,7 @@ Rejected: - `users/core/settings.py`: the field, plus a `model_validator` that logs `users.oauth.microsoft_trust_ignored` (WARNING) when the setting is on and the - tenant is `common`/`organizations`/`consumers`/any non-UUID. Not an error: a + tenant is `common`/`organizations`/`consumers` (or its GUID)/any non-canonical-GUID. Not an error: a settings edit in the admin UI must not fail, and the safe behaviour (no trust) is what applies. - `users/oauth/providers.py`: `OAuthProvider.trust_email`; the helper @@ -55,7 +55,9 @@ Rejected: ## Decisions made - Setting name from the issue (`oauth_microsoft_trust_tenant_upn`), default off. -- UUID check via `uuid.UUID(...)`, so any GUID form is accepted; verified-domain +- GUID check via `uuid.UUID(...)`, restricted to the canonical hyphenated form + (case-insensitive) and excluding the `consumers` tenant's fixed GUID + (`9188040d-6c67-4c5b-b112-36a304b66dad`, personal accounts); verified-domain names (`contoso.onmicrosoft.com`) as tenant are **not** trusted, since the issue scopes trust to a GUID. - `#EXT#` match is case-insensitive; an empty id or UPN is never trusted. diff --git a/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py b/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py index 8f383c2c..c1c4a670 100644 --- a/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py +++ b/modules/users/tests/oauth/providers/test_users_oauth_microsoft_trust_policy.py @@ -43,7 +43,7 @@ def test_trust_applies_to_a_tenant_guid(): "", # Microsoft's personal-account tenant: a GUID, but not one directory. "9188040d-6c67-4c5b-b112-36a304b66dad", - # Non-canonical GUID spellings Entra does not accept as a tenant segment. + # Non-canonical GUID spellings (braces, no hyphens) are not a pinned tenant. "{6f1c2a3b-4d5e-4f60-8a7b-9c0d1e2f3a4b}", "6f1c2a3b4d5e4f608a7b9c0d1e2f3a4b", ], diff --git a/modules/users/users/core/settings.py b/modules/users/users/core/settings.py index 4e5ddf69..701cf855 100644 --- a/modules/users/users/core/settings.py +++ b/modules/users/users/core/settings.py @@ -239,8 +239,8 @@ def _warn_on_ignored_microsoft_trust(self) -> UsersSettings: ): logger.warning( "users.oauth.microsoft_trust_ignored — oauth_microsoft_trust_tenant_upn is on " - "but oauth_microsoft_tenant=%r is not a tenant GUID; Microsoft emails stay " - "unverified. Set the tenant to your directory's GUID to trust member UPNs.", + "but oauth_microsoft_tenant=%r is not one work/school tenant GUID; Microsoft " + "emails stay unverified. Set it to your directory's GUID to trust member UPNs.", self.oauth_microsoft_tenant, ) return self