Skip to content

Commit 2240bf9

Browse files
authored
Parse new azd auth error formats in AzureDeveloperCliCredential (#46711)
* Parse new azd auth error formats in AzureDeveloperCliCredential * Update parsing to remove suggestion preference and use first non-empty message
1 parent ecaa30e commit 2240bf9

4 files changed

Lines changed: 152 additions & 81 deletions

File tree

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
### Bugs Fixed
1212

13+
- Fixed `AzureDeveloperCliCredential` to correctly parse error messages from Azure Developer CLI v1.23.7 and later, which previously caused raw JSON to surface in `ClientAuthenticationError` instead of the underlying error text.
14+
1315
### Other Changes
1416

1517
- Added `RequestIdPolicy` to the default pipeline policies to ensure a unique `x-ms-client-request-id` header is sent with each request. ([#46070](https://github.com/Azure/azure-sdk-for-python/pull/46070))

sdk/identity/azure-identity/azure/identity/_credentials/azd_cli.py

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -258,19 +258,25 @@ def sanitize_output(output: str) -> str:
258258

259259
def extract_cli_error_message(output: str) -> Optional[str]:
260260
"""
261-
Extract a single, user-friendly message from azd consoleMessage JSON output.
261+
Extract a single, user-friendly message from azd's stderr JSON output.
262+
263+
azd writes JSON error messages to stderr. The format depends on the azd version:
264+
265+
* v1.24.0+: ``{"error":"...","message":"...","suggestion":"..."}`` (single line)
266+
* v1.23.7 - v1.23.15: an empty ``{"type":"consoleMessage",...}`` line followed by
267+
the structured ``{"error":"..."}`` line
268+
* pre-v1.23.7 (legacy): a single ``{"type":"consoleMessage","data":{"message":"..."}}``
269+
line whose ``data.message`` carries the entire ``ERROR: ...`` output
270+
271+
The structured ``"error"`` field is preferred when present; otherwise the function falls
272+
back to the first non-empty legacy ``consoleMessage`` ``data.message``. Returns ``None``
273+
if no message can be extracted.
262274
263275
:param str output: The output from the Azure Developer CLI command.
264276
:return: A user-friendly error message if found, otherwise None.
265277
:rtype: Optional[str]
266-
267-
Preference order:
268-
1) A message containing "Suggestion" (case-insensitive)
269-
2) The second message if multiple are present
270-
3) The first message if only one exists
271-
Returns None if no messages can be parsed.
272278
"""
273-
messages: List[str] = []
279+
fallback: Optional[str] = None
274280
for line in output.splitlines():
275281
line = line.strip()
276282
if not line:
@@ -279,29 +285,23 @@ def extract_cli_error_message(output: str) -> Optional[str]:
279285
obj = json.loads(line)
280286
except json.JSONDecodeError: # not JSON -> ignore
281287
continue
282-
if isinstance(obj, dict):
288+
if not isinstance(obj, dict):
289+
continue
290+
291+
# Prefer the structured "error" field (azd v1.23.7+).
292+
err = obj.get("error")
293+
if isinstance(err, str) and err.strip():
294+
return sanitize_output(err.strip())
295+
296+
# Fall back to the first non-empty legacy consoleMessage data.message.
297+
if fallback is None:
283298
data = obj.get("data")
284299
if isinstance(data, dict):
285300
msg = data.get("message")
286301
if isinstance(msg, str) and msg.strip():
287-
messages.append(msg.strip())
288-
continue
289-
msg = obj.get("message")
290-
if isinstance(msg, str) and msg.strip():
291-
messages.append(msg.strip())
292-
293-
if not messages:
294-
return None
295-
296-
# Prefer the suggestion line if present
297-
for msg in messages:
298-
if "suggestion" in msg.lower():
299-
return sanitize_output(msg)
302+
fallback = msg.strip()
300303

301-
# If more than one message exists, return the last one
302-
if len(messages) > 1:
303-
return sanitize_output(messages[-1])
304-
return sanitize_output(messages[0])
304+
return sanitize_output(fallback) if fallback else None
305305

306306

307307
def _run_command(command_args: List[str], timeout: int) -> str:

sdk/identity/azure-identity/tests/test_azd_cli_credential.py

Lines changed: 119 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,11 @@ def test_claims_challenge_raises_error(get_token_method):
346346
claims = '{"access_token":{"acrs":{"essential":true,"values":["p1"]}}}'
347347
credential = AzureDeveloperCliCredential()
348348

349-
expected_message = "Suggestion: re-authentication required, run `azd auth login` to acquire a new token."
349+
expected_message = (
350+
"ERROR: fetching token: AADSTS50076: Due to a configuration change made by your administrator, "
351+
"or because you moved to a new location, you must use multi-factor authentication to access "
352+
"'tenant-id'. Trace ID: trace-id Correlation ID: correlation-id Timestamp: 2025-08-18 22:08:14Z"
353+
)
350354
error_output = """\
351355
{"data":{"message":"\\nERROR: fetching token: AADSTS50076: Due to a configuration change made by your administrator, or because you moved to a new location, you must use multi-factor authentication to access 'tenant-id'. Trace ID: trace-id Correlation ID: correlation-id Timestamp: 2025-08-18 22:08:14Z\\n"}}
352356
{"data":{"message":"Suggestion: re-authentication required, run `azd auth login` to acquire a new token.\\n"}}"""
@@ -439,36 +443,8 @@ def fake_check_output(command_line, **kwargs):
439443
class TestExtractCliErrorMessage:
440444
"""Tests for the error message extraction function."""
441445

442-
def test_extract_suggestion_message_preferred(self):
443-
"""Should prefer messages containing 'Suggestion' (case-insensitive)"""
444-
output = """\
445-
{"type":"consoleMessage","timestamp":"2025-08-18T15:08:14.4849845-07:00","data":{"message":"\\nERROR: fetching token: AADSTS50076: Due to a configuration change made by your administrator, or because you moved to a new location, you must use multi-factor authentication to access 'tenant-id'. Trace ID: trace-id Correlation ID: correlation-id Timestamp: 2025-08-18 22:08:14Z\\n"}}
446-
{"type":"consoleMessage","timestamp":"2025-08-18T15:08:14.4849845-07:00","data":{"message":"Suggestion: re-authentication required, run `azd auth login` to acquire a new token.\\n"}}"""
447-
448-
result = extract_cli_error_message(output)
449-
assert result == "Suggestion: re-authentication required, run `azd auth login` to acquire a new token."
450-
451-
def test_extract_suggestion_case_insensitive(self):
452-
"""Should find 'suggestion' in any case"""
453-
output = """\
454-
{"type":"consoleMessage","data":{"message":"First message"}}
455-
{"type":"consoleMessage","data":{"message":"SUGGESTION: Try running azd auth login"}}"""
456-
457-
result = extract_cli_error_message(output)
458-
assert result == "SUGGESTION: Try running azd auth login"
459-
460-
def test_extract_last_message_when_no_suggestion(self):
461-
"""Should return last message when multiple messages but no suggestion"""
462-
output = """\
463-
{"type":"consoleMessage","data":{"message":"First error message"}}
464-
{"type":"consoleMessage","data":{"message":"Second error message"}}
465-
{"type":"consoleMessage","data":{"message":"Third error message"}}"""
466-
467-
result = extract_cli_error_message(output)
468-
assert result == "Third error message"
469-
470446
def test_extract_first_message_when_only_one(self):
471-
"""Should return first message when only one exists"""
447+
"""Should return the first message when only one exists"""
472448
output = '{"type":"consoleMessage","data":{"message":"Only error message"}}'
473449

474450
result = extract_cli_error_message(output)
@@ -481,33 +457,52 @@ def test_extract_message_from_nested_data(self):
481457
result = extract_cli_error_message(output)
482458
assert result == "Error in nested data"
483459

484-
def test_extract_message_from_root_level(self):
485-
"""Should extract message from root level of JSON"""
486-
output = '{"message":"Root level error message"}'
460+
def test_extract_first_non_empty_data_message(self):
461+
"""Should return the first non-empty data.message; later messages are ignored.
462+
463+
``azd auth token`` (pre-v1.23.7) emits the entire ``ERROR: ... Suggestion: ...``
464+
blob inside a single ``consoleMessage`` line, so multi-line legacy output is
465+
only seen if a separate ``Message(ctx, "")`` precedes it. The first non-empty
466+
line is always the substantive error.
467+
"""
468+
output = """\
469+
{"type":"consoleMessage","timestamp":"2025-08-18T15:08:14.4849845-07:00","data":{"message":"\\nERROR: fetching token: AADSTS50076: multi-factor authentication required\\n\\nSuggestion: re-authentication required, run `azd auth login` to acquire a new token.\\n"}}"""
487470

488471
result = extract_cli_error_message(output)
489-
assert result == "Root level error message"
472+
assert result == (
473+
"ERROR: fetching token: AADSTS50076: multi-factor authentication required\n\n"
474+
"Suggestion: re-authentication required, run `azd auth login` to acquire a new token."
475+
)
490476

491-
def test_extract_mixed_message_locations(self):
492-
"""Should handle messages at different JSON levels"""
477+
def test_extract_first_message_when_multiple_present(self):
478+
"""Multiple consoleMessage lines: first non-empty wins (matches Go/.NET)."""
493479
output = """\
494-
{"message":"Root level message"}
495-
{"data":{"message":"Nested message"}}
496-
{"data":{"message":"suggestion: Use this suggestion"}}"""
480+
{"type":"consoleMessage","data":{"message":"First error message"}}
481+
{"type":"consoleMessage","data":{"message":"Second error message"}}
482+
{"type":"consoleMessage","data":{"message":"Third error message"}}"""
497483

498484
result = extract_cli_error_message(output)
499-
assert result == "suggestion: Use this suggestion"
485+
assert result == "First error message"
500486

501-
def test_ignore_empty_messages(self):
502-
"""Should ignore empty or whitespace-only messages"""
487+
def test_skip_empty_console_message_then_take_first_non_empty(self):
488+
"""Empty consoleMessage lines are skipped; the first non-empty one is returned."""
503489
output = """\
504-
{"data":{"message":" "}}
505-
{"data":{"message":""}}
506-
{"data":{"message":"Valid message"}}"""
490+
{"type":"consoleMessage","data":{"message":" "}}
491+
{"type":"consoleMessage","data":{"message":""}}
492+
{"type":"consoleMessage","data":{"message":"Valid message"}}"""
507493

508494
result = extract_cli_error_message(output)
509495
assert result == "Valid message"
510496

497+
def test_root_level_message_field_not_extracted(self):
498+
"""Only the structured ``error`` field or legacy ``data.message`` is extracted."""
499+
# Root-level "message" alone (no "error", no "data.message") is friendly text like
500+
# "Authentication with Azure failed." that we intentionally do not surface.
501+
output = '{"message":"Root level error message"}'
502+
503+
result = extract_cli_error_message(output)
504+
assert result is None
505+
511506
def test_ignore_non_json_lines(self):
512507
"""Should ignore lines that are not valid JSON"""
513508
output = """\
@@ -517,7 +512,7 @@ def test_ignore_non_json_lines(self):
517512
{"data":{"message":"Suggestion: Another valid message"}}"""
518513

519514
result = extract_cli_error_message(output)
520-
assert result == "Suggestion: Another valid message"
515+
assert result == "Valid JSON message"
521516

522517
def test_ignore_non_string_messages(self):
523518
"""Should ignore messages that are not strings"""
@@ -540,7 +535,7 @@ def test_ignore_empty_lines(self):
540535
"""
541536

542537
result = extract_cli_error_message(output)
543-
assert result == "Second message"
538+
assert result == "First message"
544539

545540
def test_sanitize_token_in_output(self):
546541
"""Should sanitize tokens in the extracted message"""
@@ -571,15 +566,20 @@ def test_return_none_for_whitespace_only_output(self):
571566
result = extract_cli_error_message(" \n\n \t ")
572567
assert result is None
573568

574-
def test_complex_real_world_example(self):
575-
"""Should handle complex real-world azd output"""
576-
output = """\
577-
{"type":"consoleMessage","timestamp":"2025-08-18T15:08:14.4849845-07:00","data":{"message":"\\nERROR: fetching token: AADSTS50076: Due to a configuration change made by your administrator, or because you moved to a new location, you must use multi-factor authentication to access 'tenant-id'. Trace ID: trace-id Correlation ID: correlation-id Timestamp: 2025-08-18 22:08:14Z\\n"}}
578-
{"type":"consoleMessage","timestamp":"2025-08-18T15:08:14.4849845-07:00","data":{"message":"Suggestion: re-authentication required, run `azd auth login` to acquire a new token.\\n"}}
579-
{"type":"progress","data":{"activity":"Cleaning up"}}"""
569+
def test_complex_real_world_pre_v1_23_7_example(self):
570+
"""Pre-v1.23.7 ``azd auth token`` output: a single consoleMessage containing the full error."""
571+
output = (
572+
'{"type":"consoleMessage","timestamp":"2026-04-14T22:03:37.687535934Z",'
573+
'"data":{"message":"\\nERROR: fetching token: failed to authenticate:\\n'
574+
"(invalid_tenant) AADSTS90002: Tenant 'test' not found...\\n\\n"
575+
'"}}'
576+
)
580577

581578
result = extract_cli_error_message(output)
582-
assert result == "Suggestion: re-authentication required, run `azd auth login` to acquire a new token."
579+
assert result == (
580+
"ERROR: fetching token: failed to authenticate:\n"
581+
"(invalid_tenant) AADSTS90002: Tenant 'test' not found..."
582+
)
583583

584584
def test_strip_whitespace_from_messages(self):
585585
"""Should strip leading and trailing whitespace from messages"""
@@ -597,3 +597,68 @@ def test_handle_malformed_json_gracefully(self):
597597

598598
result = extract_cli_error_message(output)
599599
assert result == "suggestion: This should be found"
600+
601+
def test_extract_structured_error_v1_24_format(self):
602+
"""Should extract the 'error' field from azd v1.24.0+ structured stderr."""
603+
# The AAD error here intentionally contains an embedded newline (decoded from \n in JSON).
604+
aad_error = "fetching token: failed to authenticate:\n" "(invalid_tenant) AADSTS90002: Tenant 'test' not found"
605+
output = (
606+
'{"error":"fetching token: failed to authenticate:\\n'
607+
"(invalid_tenant) AADSTS90002: Tenant 'test' not found"
608+
'","links":[{"title":"azd auth login reference","url":"https://example.com"}],'
609+
'"message":"Authentication with Azure failed.",'
610+
'"suggestion":"Run \'azd auth login\' to sign in again."}'
611+
)
612+
613+
result = extract_cli_error_message(output)
614+
assert result == aad_error
615+
assert "Authentication with Azure failed." not in result
616+
assert "suggestion" not in result.lower()
617+
618+
def test_extract_structured_error_preceded_by_empty_console_message(self):
619+
"""v1.23.7 - v1.23.15: empty consoleMessage line precedes the structured error."""
620+
aad_error = "fetching token: failed to authenticate"
621+
output = (
622+
'{"type":"consoleMessage","timestamp":"2026-04-13T17:43:24.7558297-07:00",'
623+
'"data":{"message":"\\n"}}\n'
624+
'{"error":"' + aad_error + '",'
625+
'"message":"Authentication with Azure failed.",'
626+
'"suggestion":"Run \'azd auth login\' to sign in again."}'
627+
)
628+
629+
result = extract_cli_error_message(output)
630+
assert result == aad_error
631+
assert "consoleMessage" not in result
632+
633+
def test_structured_error_takes_precedence_over_console_message(self):
634+
"""The structured 'error' field should win over a non-empty consoleMessage line."""
635+
aad_error = "AADSTS70008: Refresh token expired"
636+
output = (
637+
'{"type":"consoleMessage","timestamp":"...",'
638+
'"data":{"message":"some informational console output"}}\n'
639+
'{"error":"' + aad_error + '",'
640+
'"message":"Authentication with Azure failed."}'
641+
)
642+
643+
result = extract_cli_error_message(output)
644+
assert result == aad_error
645+
assert "informational console output" not in result
646+
647+
def test_structured_error_empty_falls_back_to_console_message(self):
648+
"""An empty 'error' field shouldn't short-circuit; legacy parsing should still run."""
649+
output = (
650+
'{"error":"","message":"Authentication with Azure failed."}\n'
651+
'{"type":"consoleMessage","data":{"message":"ERROR: real message"}}'
652+
)
653+
654+
result = extract_cli_error_message(output)
655+
assert result == "ERROR: real message"
656+
657+
def test_structured_error_sanitizes_token(self):
658+
"""Tokens embedded in the structured 'error' field should be sanitized."""
659+
output = '{"error":"got \\"token\\": \\"secret-abc\\" leaked"}'
660+
661+
result = extract_cli_error_message(output)
662+
assert result is not None
663+
assert "secret-abc" not in result
664+
assert "****" in result

sdk/identity/azure-identity/tests/test_azd_cli_credential_async.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,11 @@ async def test_claims_challenge_raises_error(get_token_method):
342342
claims = '{"access_token":{"acrs":{"essential":true,"values":["p1"]}}}'
343343
credential = AzureDeveloperCliCredential()
344344

345-
expected_message = "Suggestion: re-authentication required, run `azd auth login` to acquire a new token."
345+
expected_message = (
346+
"ERROR: fetching token: AADSTS50076: Due to a configuration change made by your administrator, "
347+
"or because you moved to a new location, you must use multi-factor authentication to access "
348+
"'tenant-id'. Trace ID: trace-id Correlation ID: correlation-id Timestamp: 2025-08-18 22:08:14Z"
349+
)
346350
error_output = """\
347351
{"data":{"message":"\\nERROR: fetching token: AADSTS50076: Due to a configuration change made by your administrator, or because you moved to a new location, you must use multi-factor authentication to access 'tenant-id'. Trace ID: trace-id Correlation ID: correlation-id Timestamp: 2025-08-18 22:08:14Z\\n"}}
348352
{"data":{"message":"Suggestion: re-authentication required, run `azd auth login` to acquire a new token.\\n"}}"""

0 commit comments

Comments
 (0)