@@ -280,6 +280,10 @@ def verify_agent_authorization(
280280 return False
281281
282282
283+ # Maximum number of authoritative_location redirects to follow
284+ MAX_REDIRECT_DEPTH = 5
285+
286+
283287async def fetch_adagents (
284288 publisher_domain : str ,
285289 timeout : float = 10.0 ,
@@ -288,6 +292,11 @@ async def fetch_adagents(
288292) -> dict [str , Any ]:
289293 """Fetch and parse adagents.json from publisher domain.
290294
295+ Follows authoritative_location redirects per the AdCP specification. When a
296+ publisher's adagents.json contains an authoritative_location field instead of
297+ authorized_agents, this function fetches the referenced URL to get the actual
298+ authorization data.
299+
291300 Args:
292301 publisher_domain: Domain hosting the adagents.json file
293302 timeout: Request timeout in seconds
@@ -297,11 +306,12 @@ async def fetch_adagents(
297306 If None, a new client is created for this request.
298307
299308 Returns:
300- Parsed adagents.json data
309+ Parsed adagents.json data (resolved from authoritative_location if present)
301310
302311 Raises:
303312 AdagentsNotFoundError: If adagents.json not found (404)
304- AdagentsValidationError: If JSON is invalid or malformed
313+ AdagentsValidationError: If JSON is invalid, malformed, or redirects
314+ exceed maximum depth or form a loop
305315 AdagentsTimeoutError: If request times out
306316
307317 Notes:
@@ -311,21 +321,74 @@ async def fetch_adagents(
311321 # Validate and normalize domain for security
312322 publisher_domain = _validate_publisher_domain (publisher_domain )
313323
314- # Construct URL
324+ # Construct initial URL
315325 url = f"https://{ publisher_domain } /.well-known/adagents.json"
316326
327+ # Track visited URLs to detect loops
328+ visited_urls : set [str ] = set ()
329+
330+ for depth in range (MAX_REDIRECT_DEPTH + 1 ):
331+ # Check for redirect loop
332+ if url in visited_urls :
333+ raise AdagentsValidationError (
334+ f"Circular redirect detected: { url } already visited"
335+ )
336+ visited_urls .add (url )
337+
338+ data = await _fetch_adagents_url (url , timeout , user_agent , client )
339+
340+ # Check if this is a redirect. A response with authoritative_location but no
341+ # authorized_agents indicates a redirect. If both are present, authorized_agents
342+ # takes precedence (response is treated as final).
343+ if "authoritative_location" in data and "authorized_agents" not in data :
344+ authoritative_url = data ["authoritative_location" ]
345+
346+ # Validate HTTPS requirement
347+ if not isinstance (authoritative_url , str ) or not authoritative_url .startswith (
348+ "https://"
349+ ):
350+ raise AdagentsValidationError (
351+ f"authoritative_location must be an HTTPS URL, got: { authoritative_url !r} "
352+ )
353+
354+ # Check if we've exceeded max depth
355+ if depth >= MAX_REDIRECT_DEPTH :
356+ raise AdagentsValidationError (
357+ f"Maximum redirect depth ({ MAX_REDIRECT_DEPTH } ) exceeded"
358+ )
359+
360+ # Follow the redirect
361+ url = authoritative_url
362+ continue
363+
364+ # We have the final data with authorized_agents (or both fields present,
365+ # in which case authorized_agents takes precedence)
366+ return data
367+
368+ # Unreachable: loop always exits via return or raise above
369+ raise AssertionError ("Unreachable" ) # pragma: no cover
370+
371+
372+ async def _fetch_adagents_url (
373+ url : str ,
374+ timeout : float ,
375+ user_agent : str ,
376+ client : httpx .AsyncClient | None ,
377+ ) -> dict [str , Any ]:
378+ """Fetch and parse adagents.json from a specific URL.
379+
380+ This is the core fetch logic, separated to support redirect following.
381+ """
317382 try :
318383 # Use provided client or create a new one
319384 if client is not None :
320- # Reuse provided client (connection pooling)
321385 response = await client .get (
322386 url ,
323387 headers = {"User-Agent" : user_agent },
324388 timeout = timeout ,
325389 follow_redirects = True ,
326390 )
327391 else :
328- # Create new client for single request
329392 async with httpx .AsyncClient () as new_client :
330393 response = await new_client .get (
331394 url ,
@@ -334,9 +397,11 @@ async def fetch_adagents(
334397 follow_redirects = True ,
335398 )
336399
337- # Process response (same for both paths)
400+ # Process response
338401 if response .status_code == 404 :
339- raise AdagentsNotFoundError (publisher_domain )
402+ # Extract domain from URL for error message
403+ parsed = urlparse (url )
404+ raise AdagentsNotFoundError (parsed .netloc )
340405
341406 if response .status_code != 200 :
342407 raise AdagentsValidationError (
@@ -353,22 +418,29 @@ async def fetch_adagents(
353418 if not isinstance (data , dict ):
354419 raise AdagentsValidationError ("adagents.json must be a JSON object" )
355420
356- if "authorized_agents" not in data :
357- raise AdagentsValidationError ("adagents.json must have 'authorized_agents' field" )
358-
359- if not isinstance (data ["authorized_agents" ], list ):
360- raise AdagentsValidationError ("'authorized_agents' must be an array" )
361-
362- # Validate mutual exclusivity constraints
363- try :
364- validate_adagents (data )
365- except ValidationError as e :
366- raise AdagentsValidationError (f"Invalid adagents.json structure: { e } " ) from e
421+ # If this has authorized_agents, validate it
422+ if "authorized_agents" in data :
423+ if not isinstance (data ["authorized_agents" ], list ):
424+ raise AdagentsValidationError ("'authorized_agents' must be an array" )
425+
426+ # Validate mutual exclusivity constraints
427+ try :
428+ validate_adagents (data )
429+ except ValidationError as e :
430+ raise AdagentsValidationError (
431+ f"Invalid adagents.json structure: { e } "
432+ ) from e
433+ elif "authoritative_location" not in data :
434+ # Neither authorized_agents nor authoritative_location
435+ raise AdagentsValidationError (
436+ "adagents.json must have either 'authorized_agents' or 'authoritative_location'"
437+ )
367438
368439 return data
369440
370441 except httpx .TimeoutException as e :
371- raise AdagentsTimeoutError (publisher_domain , timeout ) from e
442+ parsed = urlparse (url )
443+ raise AdagentsTimeoutError (parsed .netloc , timeout ) from e
372444 except httpx .RequestError as e :
373445 raise AdagentsValidationError (f"Failed to fetch adagents.json: { e } " ) from e
374446
0 commit comments