Skip to content

Commit d02db4d

Browse files
xiangyan99Copilotscottaddieweikanglimpvaneck
authored
Identity mfa support (#42568)
* Initial plan * Implement claims challenge error for AzureCliCredential get_token and get_token_info methods Co-authored-by: xiangyan99 <[email protected]> * Refine claims challenge handling to ignore whitespace-only claims and add comprehensive tests Co-authored-by: xiangyan99 <[email protected]> * Fix MyPy errors in AzureCliCredential claims challenge handling Co-authored-by: xiangyan99 <[email protected]> * Fix pylint line-too-long and trailing-whitespace errors in AzureCliCredential Co-authored-by: xiangyan99 <[email protected]> * Apply black formatting to AzureCliCredential files Co-authored-by: xiangyan99 <[email protected]> * black * Update error message format and docstrings for claims challenge handling - Change error message from "Fail to get token, please run" to "Failed to get token. Run" - Update docstrings to clarify error conditions as requested in code review - Update test expectations to match new error message format Co-authored-by: scottaddie <[email protected]> * black * Include scopes in az login command for claims challenge error messages Co-authored-by: weikanglim <[email protected]> * Refactor claims challenge logic to _get_token_base and add tenant support - Move claims challenge logic from get_token and get_token_info methods to _get_token_base method to eliminate code duplication - Pass claims through TokenRequestOptions instead of checking directly in individual methods - Add tenant_id support in error messages when provided in options - Update error message format to include tenant: "az login --claims-challenge {claims} [--tenant {tenant}] [--scope {scope}]" - Add comprehensive tests for tenant functionality in both sync and async versions - Maintains backward compatibility while providing more complete actionable commands Co-authored-by: pvaneck <[email protected]> * Added MFA support for developer credentials. * Minor refactor and test updates Signed-off-by: Paul Van Eck <[email protected]> * Update error messages Signed-off-by: Paul Van Eck <[email protected]> --------- Signed-off-by: Paul Van Eck <[email protected]> Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: xiangyan99 <[email protected]> Co-authored-by: scottaddie <[email protected]> Co-authored-by: weikanglim <[email protected]> Co-authored-by: pvaneck <[email protected]> Co-authored-by: Paul Van Eck <[email protected]>
1 parent f4d5d44 commit d02db4d

13 files changed

+861
-51
lines changed

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- `AzureDeveloperCliCredential` now supports `claims` in `get_token` and `get_token_info`. ([#42568](https://github.com/Azure/azure-sdk-for-python/pull/42568))
8+
79
### Breaking Changes
810

911
### Bugs Fixed
@@ -23,6 +25,7 @@
2325

2426
- `ManagedIdentityCredential` now retries IMDS 410 status responses for at least 70 seconds total duration as required by [Azure IMDS documentation](https://learn.microsoft.com/azure/virtual-machines/instance-metadata-service?tabs=windows#errors-and-debugging). ([#42330](https://github.com/Azure/azure-sdk-for-python/pull/42330))
2527
- Improved `DefaultAzureCredential` diagnostics when `WorkloadIdentityCredential` initialization fails. If DAC fails to find a successful credential in the chain, the reason `WorkloadIdentityCredential` failed will be included in the error message. ([#42346](https://github.com/Azure/azure-sdk-for-python/pull/42346))
28+
- `AzureCliCredential` and `AzurePowerShellCredential` now raise `ClientAuthenticationError` when `claims` are provided to `get_token` or `get_token_info`, as these credentials do not support claims challenges. The error message includes instructions for handling claims authentication scenarios. ([#42568](https://github.com/Azure/azure-sdk-for-python/pull/42568))
2629

2730
## 1.24.0b1 (2025-07-17)
2831

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

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"Please visit https://aka.ms/azure-dev for installation instructions and then,"
2929
"once installed, authenticate to your Azure account using 'azd auth login'."
3030
)
31+
UNKNOWN_CLAIMS_FLAG = (
32+
"Claims challenges are not supported by the Azure Developer CLI version you are using. "
33+
"Please update to version 1.18.1 or later."
34+
)
3135
COMMAND_LINE = ["auth", "token", "--output", "json", "--no-prompt"]
3236
EXECUTABLE_NAME = "azd"
3337
NOT_LOGGED_IN = "Please run 'azd auth login' from a command prompt to authenticate before using this credential."
@@ -99,7 +103,7 @@ def close(self) -> None:
99103
def get_token(
100104
self,
101105
*scopes: str,
102-
claims: Optional[str] = None, # pylint:disable=unused-argument
106+
claims: Optional[str] = None,
103107
tenant_id: Optional[str] = None,
104108
**kwargs: Any,
105109
) -> AccessToken:
@@ -111,7 +115,8 @@ def get_token(
111115
:param str scopes: desired scope for the access token. This credential allows only one scope per request.
112116
For more information about scopes, see
113117
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
114-
:keyword str claims: not used by this credential; any value provided will be ignored.
118+
:keyword str claims: additional claims required in the token, such as those returned in a resource provider's
119+
claims challenge following an authorization failure.
115120
:keyword str tenant_id: optional tenant to include in the token request.
116121
117122
:return: An access token with the desired scopes.
@@ -125,6 +130,8 @@ def get_token(
125130
options: TokenRequestOptions = {}
126131
if tenant_id:
127132
options["tenant_id"] = tenant_id
133+
if claims:
134+
options["claims"] = claims
128135

129136
token_info = self._get_token_base(*scopes, options=options, **kwargs)
130137
return AccessToken(token_info.token, token_info.expires_on)
@@ -159,6 +166,7 @@ def _get_token_base(
159166
raise ValueError("Missing scope in request. \n")
160167

161168
tenant_id = options.get("tenant_id") if options else None
169+
claims = options.get("claims") if options else None
162170
if tenant_id:
163171
validate_tenant_id(tenant_id)
164172
for scope in scopes:
@@ -175,16 +183,23 @@ def _get_token_base(
175183
)
176184
if tenant:
177185
command_args += ["--tenant-id", tenant]
186+
if claims:
187+
command_args += ["--claims", claims]
178188
output = _run_command(command_args, self._process_timeout)
179189

180190
token = parse_token(output)
181191
if not token:
182-
sanitized_output = sanitize_output(output)
183-
message = (
184-
f"Unexpected output from Azure Developer CLI: '{sanitized_output}'. \n"
185-
f"To mitigate this issue, please refer to the troubleshooting guidelines here at "
186-
f"https://aka.ms/azsdk/python/identity/azdevclicredential/troubleshoot."
187-
)
192+
# Try to extract a meaningful error from azd consoleMessage JSON lines
193+
extracted = extract_cli_error_message(output)
194+
if extracted:
195+
message = extracted
196+
else:
197+
sanitized_output = sanitize_output(output)
198+
message = (
199+
f"Unexpected output from Azure Developer CLI: '{sanitized_output}'. \n"
200+
f"To mitigate this issue, please refer to the troubleshooting guidelines here at "
201+
f"https://aka.ms/azsdk/python/identity/azdevclicredential/troubleshoot."
202+
)
188203
if within_dac.get():
189204
raise CredentialUnavailableError(message=message)
190205
raise ClientAuthenticationError(message=message)
@@ -241,6 +256,54 @@ def sanitize_output(output: str) -> str:
241256
return re.sub(r"\"token\": \"(.*?)(\"|$)", "****", output)
242257

243258

259+
def extract_cli_error_message(output: str) -> Optional[str]:
260+
"""
261+
Extract a single, user-friendly message from azd consoleMessage JSON output.
262+
263+
:param str output: The output from the Azure Developer CLI command.
264+
:return: A user-friendly error message if found, otherwise None.
265+
: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.
272+
"""
273+
messages: List[str] = []
274+
for line in output.splitlines():
275+
line = line.strip()
276+
if not line:
277+
continue
278+
try:
279+
obj = json.loads(line)
280+
except json.JSONDecodeError: # not JSON -> ignore
281+
continue
282+
if isinstance(obj, dict):
283+
data = obj.get("data")
284+
if isinstance(data, dict):
285+
msg = data.get("message")
286+
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)
300+
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])
305+
306+
244307
def _run_command(command_args: List[str], timeout: int) -> str:
245308
# Ensure executable exists in PATH first. This avoids a subprocess call that would fail anyway.
246309
azd_path = shutil.which(EXECUTABLE_NAME)
@@ -267,16 +330,18 @@ def _run_command(command_args: List[str], timeout: int) -> str:
267330
# Fallback check in case the executable is not found while executing subprocess.
268331
if ex.returncode == 127 or (ex.stderr is not None and ex.stderr.startswith("'azd' is not recognized")):
269332
raise CredentialUnavailableError(message=CLI_NOT_FOUND) from ex
270-
if ex.stderr is not None and (
271-
"not logged in, run `azd auth login` to login" in ex.stderr and "AADSTS" not in ex.stderr
272-
):
333+
combined_text = "{}\n{}".format(ex.output or "", ex.stderr or "")
334+
if "not logged in, run `azd auth login` to login" in combined_text and "AADSTS" not in combined_text:
273335
raise CredentialUnavailableError(message=NOT_LOGGED_IN) from ex
336+
if "unknown flag: --claims" in combined_text:
337+
raise CredentialUnavailableError(message=UNKNOWN_CLAIMS_FLAG) from ex
274338

275339
# return code is from the CLI -> propagate its output
276-
if ex.stderr:
277-
message = sanitize_output(ex.stderr)
278-
else:
279-
message = "Failed to invoke Azure Developer CLI"
340+
message = (
341+
extract_cli_error_message(ex.output or "")
342+
or extract_cli_error_message(ex.stderr or "")
343+
or (sanitize_output(ex.stderr) if ex.stderr else "Failed to invoke Azure Developer CLI")
344+
)
280345
if within_dac.get():
281346
raise CredentialUnavailableError(message=message) from ex
282347
raise ClientAuthenticationError(message=message) from ex

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
COMMAND_LINE = ["account", "get-access-token", "--output", "json"]
3535
EXECUTABLE_NAME = "az"
3636
NOT_LOGGED_IN = "Please run 'az login' to set up an account"
37+
CLAIMS_UNSUPPORTED_ERROR = (
38+
"This credential doesn't support claims challenges. To authenticate with the required "
39+
"claims, please run the following command (requires Azure CLI version 2.76.0 or later): "
40+
"az login --claims-challenge {claims_value}"
41+
)
3742

3843

3944
class AzureCliCredential:
@@ -90,7 +95,7 @@ def close(self) -> None:
9095
def get_token(
9196
self,
9297
*scopes: str,
93-
claims: Optional[str] = None, # pylint:disable=unused-argument
98+
claims: Optional[str] = None,
9499
tenant_id: Optional[str] = None,
95100
**kwargs: Any,
96101
) -> AccessToken:
@@ -102,20 +107,23 @@ def get_token(
102107
:param str scopes: desired scope for the access token. This credential allows only one scope per request.
103108
For more information about scopes, see
104109
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
105-
:keyword str claims: not used by this credential; any value provided will be ignored.
110+
:keyword str claims: additional claims required in the token. This credential does not support claims
111+
challenges.
106112
:keyword str tenant_id: optional tenant to include in the token request.
107113
108114
:return: An access token with the desired scopes.
109115
:rtype: ~azure.core.credentials.AccessToken
110116
111-
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
117+
:raises ~azure.identity.CredentialUnavailableError: the credential was either unable to invoke the Azure CLI
118+
or a claims challenge was provided.
112119
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
113120
receive an access token.
114121
"""
115-
116122
options: TokenRequestOptions = {}
117123
if tenant_id:
118124
options["tenant_id"] = tenant_id
125+
if claims:
126+
options["claims"] = claims
119127

120128
token_info = self._get_token_base(*scopes, options=options, **kwargs)
121129
return AccessToken(token_info.token, token_info.expires_on)
@@ -136,7 +144,8 @@ def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] =
136144
:rtype: ~azure.core.credentials.AccessTokenInfo
137145
:return: An AccessTokenInfo instance containing information about the token.
138146
139-
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
147+
:raises ~azure.identity.CredentialUnavailableError: the credential was either unable to invoke the Azure CLI
148+
or a claims challenge was provided.
140149
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
141150
receive an access token.
142151
"""
@@ -145,6 +154,19 @@ def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] =
145154
def _get_token_base(
146155
self, *scopes: str, options: Optional[TokenRequestOptions] = None, **kwargs: Any
147156
) -> AccessTokenInfo:
157+
# Check for claims challenge first
158+
if options and options.get("claims"):
159+
error_message = CLAIMS_UNSUPPORTED_ERROR.format(claims_value=options.get("claims"))
160+
161+
# Add tenant if provided in options
162+
if options.get("tenant_id"):
163+
error_message += f" --tenant {options.get('tenant_id')}"
164+
165+
# Add scope if provided
166+
if scopes:
167+
error_message += f" --scope {scopes[0]}"
168+
169+
raise CredentialUnavailableError(message=error_message)
148170

149171
tenant_id = options.get("tenant_id") if options else None
150172
if tenant_id:

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313

1414
from .azure_cli import get_safe_working_dir
1515
from .. import CredentialUnavailableError
16-
from .._internal import _scopes_to_resource, resolve_tenant, within_dac, validate_tenant_id, validate_scope
16+
from .._internal import (
17+
_scopes_to_resource,
18+
resolve_tenant,
19+
within_dac,
20+
validate_tenant_id,
21+
validate_scope,
22+
)
1723
from .._internal.decorators import log_get_token
1824

1925

@@ -24,6 +30,11 @@
2430
NO_AZ_ACCOUNT_MODULE = "NO_AZ_ACCOUNT_MODULE"
2531
POWERSHELL_NOT_INSTALLED = "PowerShell is not installed"
2632
RUN_CONNECT_AZ_ACCOUNT = 'Please run "Connect-AzAccount" to set up account'
33+
CLAIMS_UNSUPPORTED_ERROR = (
34+
"This credential doesn't support claims challenges. To authenticate with the required "
35+
"claims, please run the following command (requires Az.Accounts module version 5.2.0 or later): "
36+
"Connect-AzAccount -ClaimsChallenge {claims_value}"
37+
)
2738
SCRIPT = """$ErrorActionPreference = 'Stop'
2839
[version]$minimumVersion = '2.2.0'
2940
@@ -112,7 +123,7 @@ def close(self) -> None:
112123
def get_token(
113124
self,
114125
*scopes: str,
115-
claims: Optional[str] = None, # pylint:disable=unused-argument
126+
claims: Optional[str] = None,
116127
tenant_id: Optional[str] = None,
117128
**kwargs: Any,
118129
) -> AccessToken:
@@ -135,10 +146,11 @@ def get_token(
135146
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked Azure PowerShell but didn't
136147
receive an access token
137148
"""
138-
139149
options: TokenRequestOptions = {}
140150
if tenant_id:
141151
options["tenant_id"] = tenant_id
152+
if claims:
153+
options["claims"] = claims
142154

143155
token_info = self._get_token_base(*scopes, options=options, **kwargs)
144156
return AccessToken(token_info.token, token_info.expires_on)
@@ -170,6 +182,13 @@ def _get_token_base(
170182
self, *scopes: str, options: Optional[TokenRequestOptions] = None, **kwargs: Any
171183
) -> AccessTokenInfo:
172184

185+
# Check if claims challenge is provided
186+
if options and options.get("claims"):
187+
error_message = CLAIMS_UNSUPPORTED_ERROR.format(claims_value=options.get("claims"))
188+
if options.get("tenant_id"):
189+
error_message += f" -Tenant {options.get('tenant_id')}"
190+
raise CredentialUnavailableError(message=error_message)
191+
173192
tenant_id = options.get("tenant_id") if options else None
174193
if tenant_id:
175194
validate_tenant_id(tenant_id)
@@ -269,7 +288,11 @@ def raise_for_error(return_code: int, stdout: str, stderr: str) -> None:
269288

270289
if stderr:
271290
# stderr is too noisy to include with an exception but may be useful for debugging
272-
_LOGGER.debug('%s received an error from Azure PowerShell: "%s"', AzurePowerShellCredential.__name__, stderr)
291+
_LOGGER.debug(
292+
'%s received an error from Azure PowerShell: "%s"',
293+
AzurePowerShellCredential.__name__,
294+
stderr,
295+
)
273296
raise CredentialUnavailableError(
274297
message="Failed to invoke PowerShell. Enable debug logging for additional information."
275298
)

0 commit comments

Comments
 (0)