From 9391509e8546e9edd3a593429e2e97b279768d81 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 12 Jul 2026 16:43:45 -0700 Subject: [PATCH] fix(oidc): trust Entra's graph.microsoft.com userinfo out of the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft Entra's discovery document advertises userinfo_endpoint on graph.microsoft.com — a host distinct from the login.microsoftonline.com issuer — so discover_oidc's cross-host guard rejected it and disabled OIDC unless the operator set trusted_endpoint_hosts. Add login.microsoftonline.com to the built-in KNOWN_TRUSTED_OAUTH_ENDPOINT_HOSTS allow-list (mirroring the Google entry) so Azure AD OIDC works with no extra configuration. Surfaced by the live obo integration test. --- docs/oidc.md | 18 +++++++++--------- tests/test_oidc.py | 34 ++++++++++++++++++++++++++++++++++ turnstone/core/oauth_ssrf.py | 14 ++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/oidc.md b/docs/oidc.md index 5f8e4ef3..0c79c350 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -77,17 +77,17 @@ IdP from redirecting the token-exchange POST (which carries being aimed at internal services. A few public IdPs legitimately split endpoints across hostnames. Google -is the canonical example: +and Microsoft Entra ID are the canonical examples: -| Field | Hostname | -|-------|----------| -| issuer | `accounts.google.com` | -| token_endpoint | `oauth2.googleapis.com` | -| jwks_uri | `www.googleapis.com` | -| userinfo_endpoint | `openidconnect.googleapis.com` | +| IdP | Issuer host | Cross-host endpoint(s) | +|-----|-------------|------------------------| +| Google | `accounts.google.com` | `oauth2.googleapis.com`, `www.googleapis.com`, `openidconnect.googleapis.com` | +| Microsoft Entra | `login.microsoftonline.com` | `graph.microsoft.com` (userinfo) | -Google's set is built in — operators using `https://accounts.google.com` -need no extra configuration. +Both sets are built in — operators using `https://accounts.google.com` or +`https://login.microsoftonline.com//v2.0` need no extra +configuration. (Entra's discovery document advertises `userinfo_endpoint` +on `graph.microsoft.com`, distinct from the issuer host.) For other IdPs whose discovery document references a non-issuer host, extend the allow-list explicitly: diff --git a/tests/test_oidc.py b/tests/test_oidc.py index c1988123..f5235884 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -910,6 +910,40 @@ class TestValidateDiscoveredEndpoint: asyncio.run(_run()) + def test_discover_accepts_entra_userinfo_on_graph(self): + """discover_oidc accepts Entra's cross-host userinfo on graph.microsoft.com + via the built-in allow-list, so Azure AD OIDC works out of the box with no + trusted_endpoint_hosts override.""" + tenant = "11111111-1111-1111-1111-111111111111" + config = _make_config( + issuer=f"https://login.microsoftonline.com/{tenant}/v2.0", + authorization_endpoint="", + token_endpoint="", + userinfo_endpoint="", + jwks_uri="", + ) + discovery_doc = { + "authorization_endpoint": f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize", + "token_endpoint": f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + "userinfo_endpoint": "https://graph.microsoft.com/oidc/userinfo", + "jwks_uri": f"https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys", + } + mock_response = MagicMock() + mock_response.json.return_value = discovery_doc + mock_response.raise_for_status = MagicMock() + + async def _run(): + client = _mock_async_client(lambda url: _async_return(mock_response)) + with ( + patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR), + patch("httpx.AsyncClient", return_value=client), + ): + result = await discover_oidc(config) + assert result.enabled is True + assert result.userinfo_endpoint == "https://graph.microsoft.com/oidc/userinfo" + + asyncio.run(_run()) + def test_discover_rejects_http_endpoint(self): """discover_oidc returns enabled=False when an endpoint is http:// in prod.""" config = _make_config( diff --git a/turnstone/core/oauth_ssrf.py b/turnstone/core/oauth_ssrf.py index ed7bdde7..8e091f41 100644 --- a/turnstone/core/oauth_ssrf.py +++ b/turnstone/core/oauth_ssrf.py @@ -42,6 +42,20 @@ KNOWN_TRUSTED_OAUTH_ENDPOINT_HOSTS: dict[str, frozenset[str]] = { "openidconnect.googleapis.com", } ), + # Microsoft Entra ID (commercial cloud). The tenant issuer is + # login.microsoftonline.com//v2.0, but its discovery document + # advertises userinfo_endpoint on graph.microsoft.com — a distinct host. + # Without this entry, discovery rejects the cross-host userinfo and + # disables OIDC, so every Azure AD deployment would need a manual + # trusted_endpoint_hosts override to log in. (Sovereign clouds — + # login.microsoftonline.us / *.chinacloudapi.cn — use their own graph + # hosts and can be added the same way.) + "login.microsoftonline.com": frozenset( + { + "login.microsoftonline.com", + "graph.microsoft.com", + } + ), }