1919import os
2020from dataclasses import dataclass
2121from typing import Any , ClassVar
22+ from urllib .parse import urlparse
2223
2324import httpx
2425import jwt
@@ -51,6 +52,13 @@ class EntraAuthMiddleware(BaseHTTPMiddleware):
5152 "/api/media" ,
5253 }
5354
55+ # Hosts the user's Bearer token may be forwarded to
56+ _GRAPH_HOSTS : ClassVar [set [str ]] = {"graph.microsoft.com" }
57+
58+ # Trusted URL for resolving group membership; built from a constant (not the
59+ # token-supplied endpoint) so token data cannot control the request destination.
60+ _GRAPH_MEMBER_OBJECTS_URL : ClassVar [str ] = "https://graph.microsoft.com/v1.0/me/getMemberObjects"
61+
5462 def __init__ (self , app : ASGIApp ) -> None :
5563 """Initialize the middleware with Entra ID configuration from environment variables."""
5664 super ().__init__ (app )
@@ -167,34 +175,34 @@ async def _resolve_excess_groups_async(self, claims: dict[str, Any], token: str)
167175 """
168176 Resolve group membership via Microsoft Graph when user is in >200 groups.
169177
170- When a user is in >200 groups, Entra ID replaces the `groups` claim with
171- `_claim_sources` containing a Graph API endpoint. This method calls the
172- Microsoft Graph `getMemberObjects` endpoint to retrieve transitive group
173- memberships, using the user's access token.
178+ When a user is in >200 groups, Entra ID replaces the `groups` claim with a
179+ `_claim_names` / `_claim_sources` overage pointer. This method confirms the
180+ overage is present, then calls the Microsoft Graph `getMemberObjects` endpoint
181+ to retrieve transitive group memberships, using the user's access token.
182+
183+ The Graph URL is built from a trusted constant (``_GRAPH_MEMBER_OBJECTS_URL``)
184+ rather than the token-supplied endpoint, so token data cannot control the
185+ request host, path, or port. Pagination links from the Graph response are
186+ additionally checked against the Graph allowlist (see ``_is_trusted_graph_url``).
174187
175188 Args:
176- claims: The decoded JWT claims containing _claim_sources .
189+ claims: The decoded JWT claims containing the group overage pointer .
177190 token: The raw Bearer token to forward to Graph API.
178191
179192 Returns:
180- List of group IDs the user belongs to, or empty list on failure.
193+ List of group IDs the user belongs to, or empty list on failure or
194+ when no group overage is present.
181195 """
182196 try :
183- claim_sources = claims . get ( "_claim_sources" , {})
184- src = claim_sources . get ( "src1" , {})
185- endpoint = src . get ( "endpoint" , "" )
186-
187- if not endpoint :
188- logger .debug ("No group resolution endpoint found in _claim_sources " )
197+ # Entra signals a groups overage via `_claim_names` -> source key -> `_claim_sources`.
198+ # We only confirm the overage is present; the endpoint it supplies is intentionally
199+ # ignored in favor of a trusted constant so token data cannot control the request.
200+ claim_source_key = claims . get ( "_claim_names" , {}). get ( "groups" )
201+ if not claim_source_key or claim_source_key not in claims . get ( "_claim_sources" , {}) :
202+ logger .debug ("No group overage claim source found " )
189203 return []
190204
191- # The _claim_sources endpoint may be a legacy graph.windows.net URL.
192- # Rewrite to Microsoft Graph (graph.microsoft.com) which is the
193- # supported API. The legacy Azure AD Graph was retired in 2023.
194- if "graph.windows.net" in endpoint :
195- # Legacy format: https://graph.windows.net/{tenant}/users/{oid}/getMemberObjects
196- # Graph format: https://graph.microsoft.com/v1.0/me/getMemberObjects
197- endpoint = "https://graph.microsoft.com/v1.0/me/getMemberObjects"
205+ endpoint = self ._GRAPH_MEMBER_OBJECTS_URL
198206
199207 all_group_ids : list [str ] = []
200208 async with httpx .AsyncClient () as client :
@@ -222,6 +230,9 @@ async def _resolve_excess_groups_async(self, claims: dict[str, Any], token: str)
222230 # Handle pagination — Graph may return @odata.nextLink for large results
223231 next_link = data .get ("@odata.nextLink" )
224232 while next_link :
233+ if not self ._is_trusted_graph_url (next_link ):
234+ logger .warning ("Refusing to follow untrusted pagination link: %s" , next_link )
235+ break
225236 response = await client .get (
226237 next_link ,
227238 headers = {"Authorization" : f"Bearer { token } " },
@@ -241,6 +252,16 @@ async def _resolve_excess_groups_async(self, claims: dict[str, Any], token: str)
241252 logger .warning ("Failed to resolve group memberships: %s" , e )
242253 return []
243254
255+ def _is_trusted_graph_url (self , url : str ) -> bool :
256+ """
257+ Whether *url* is an HTTPS Microsoft Graph URL safe to forward the token to.
258+
259+ Returns:
260+ True if *url* uses HTTPS and its host is in the Graph allowlist, False otherwise.
261+ """
262+ parsed = urlparse (url )
263+ return parsed .scheme == "https" and parsed .hostname in self ._GRAPH_HOSTS
264+
244265 def _validate_token (self , token : str ) -> tuple [AuthenticatedUser | None , dict [str , Any ]]:
245266 """
246267 Validate a JWT against Entra ID JWKS.
0 commit comments