fix(oidc): trust Entra's graph.microsoft.com userinfo out of the box

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.
This commit is contained in:
Patrick Buckley
2026-07-12 16:43:45 -07:00
parent 49f2266e20
commit 9391509e85
3 changed files with 57 additions and 9 deletions
+9 -9
View File
@@ -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/<tenant>/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:
+34
View File
@@ -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(
+14
View File
@@ -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/<tenant>/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",
}
),
}