Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. 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

### Fixed
Expand Down
15 changes: 14 additions & 1 deletion docs/modules/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -528,7 +529,19 @@ 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

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 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

Expand Down
22 changes: 22 additions & 0 deletions docs/superpowers/plans/2026-11-11-entra-tenant-upn-trust.md
Original file line number Diff line number Diff line change
@@ -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`.
83 changes: 83 additions & 0 deletions docs/superpowers/specs/2026-11-11-entra-tenant-upn-trust-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 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` 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`.

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/<GUID>`),
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` (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
`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
`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.
- 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.
- 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.
6 changes: 6 additions & 0 deletions modules/users/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ 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.
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 `<base-url>/api/users/auth/<provider>/callback`
(`google`, `github`, `microsoft`, `oidc`).
Expand Down
14 changes: 8 additions & 6 deletions modules/users/tests/_users_oauth_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down Expand Up @@ -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,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading