Skip to content

Commit 1541bfd

Browse files
Copilotxiangyan99
andcommitted
Implement claims challenge error for AzureCliCredential get_token and get_token_info methods
Co-authored-by: xiangyan99 <[email protected]>
1 parent a0105bb commit 1541bfd

File tree

4 files changed

+139
-8
lines changed

4 files changed

+139
-8
lines changed

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def close(self) -> None:
9090
def get_token(
9191
self,
9292
*scopes: str,
93-
claims: Optional[str] = None, # pylint:disable=unused-argument
93+
claims: Optional[str] = None,
9494
tenant_id: Optional[str] = None,
9595
**kwargs: Any,
9696
) -> AccessToken:
@@ -102,16 +102,19 @@ def get_token(
102102
:param str scopes: desired scope for the access token. This credential allows only one scope per request.
103103
For more information about scopes, see
104104
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
105-
:keyword str claims: not used by this credential; any value provided will be ignored.
105+
:keyword str claims: additional claims required in the token. This credential does not support claims challenges.
106106
:keyword str tenant_id: optional tenant to include in the token request.
107107
108108
:return: An access token with the desired scopes.
109109
:rtype: ~azure.core.credentials.AccessToken
110110
111-
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
111+
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI,
112+
or when claims challenge is provided.
112113
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
113114
receive an access token.
114115
"""
116+
if claims:
117+
raise CredentialUnavailableError(f"Fail to get token, please run az login --claims-challenge {claims}")
115118

116119
options: TokenRequestOptions = {}
117120
if tenant_id:
@@ -136,10 +139,15 @@ def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] =
136139
:rtype: ~azure.core.credentials.AccessTokenInfo
137140
:return: An AccessTokenInfo instance containing information about the token.
138141
139-
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
142+
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI,
143+
or when claims challenge is provided.
140144
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
141145
receive an access token.
142146
"""
147+
if options and options.get("claims"):
148+
claims = options["claims"]
149+
raise CredentialUnavailableError(f"Fail to get token, please run az login --claims-challenge {claims}")
150+
143151
return self._get_token_base(*scopes, options=options)
144152

145153
def _get_token_base(

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def __init__(
8282
async def get_token(
8383
self,
8484
*scopes: str,
85-
claims: Optional[str] = None, # pylint:disable=unused-argument
85+
claims: Optional[str] = None,
8686
tenant_id: Optional[str] = None,
8787
**kwargs: Any,
8888
) -> AccessToken:
@@ -94,15 +94,19 @@ async def get_token(
9494
:param str scopes: desired scope for the access token. This credential allows only one scope per request.
9595
For more information about scopes, see
9696
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
97-
:keyword str claims: not used by this credential; any value provided will be ignored.
97+
:keyword str claims: additional claims required in the token. This credential does not support claims challenges.
9898
:keyword str tenant_id: optional tenant to include in the token request.
9999
100100
:return: An access token with the desired scopes.
101101
:rtype: ~azure.core.credentials.AccessToken
102-
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
102+
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI,
103+
or when claims challenge is provided.
103104
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
104105
receive an access token.
105106
"""
107+
if claims:
108+
raise CredentialUnavailableError(f"Fail to get token, please run az login --claims-challenge {claims}")
109+
106110
# only ProactorEventLoop supports subprocesses on Windows (and it isn't the default loop on Python < 3.8)
107111
if sys.platform.startswith("win") and not isinstance(asyncio.get_event_loop(), asyncio.ProactorEventLoop):
108112
return _SyncAzureCliCredential().get_token(*scopes, tenant_id=tenant_id, **kwargs)
@@ -130,10 +134,15 @@ async def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptio
130134
:rtype: ~azure.core.credentials.AccessTokenInfo
131135
:return: An AccessTokenInfo instance containing information about the token.
132136
133-
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
137+
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI,
138+
or when claims challenge is provided.
134139
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
135140
receive an access token.
136141
"""
142+
if options and options.get("claims"):
143+
claims = options["claims"]
144+
raise CredentialUnavailableError(f"Fail to get token, please run az login --claims-challenge {claims}")
145+
137146
# only ProactorEventLoop supports subprocesses on Windows (and it isn't the default loop on Python < 3.8)
138147
if sys.platform.startswith("win") and not isinstance(asyncio.get_event_loop(), asyncio.ProactorEventLoop):
139148
return _SyncAzureCliCredential().get_token_info(*scopes, options=options)

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,56 @@ def fake_check_output(command_line, **_):
395395
kwargs = {"options": kwargs}
396396
token = getattr(credential, get_token_method)("scope", **kwargs)
397397
assert token.token == expected_token
398+
399+
400+
@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
401+
def test_claims_challenge_raises_error(get_token_method):
402+
"""The credential should raise CredentialUnavailableError when claims challenge is provided"""
403+
404+
claims = "test-claims-challenge"
405+
expected_message = f"Fail to get token, please run az login --claims-challenge {claims}"
406+
407+
if get_token_method == "get_token":
408+
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
409+
AzureCliCredential().get_token("scope", claims=claims)
410+
else: # get_token_info
411+
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
412+
AzureCliCredential().get_token_info("scope", options={"claims": claims})
413+
414+
415+
@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
416+
def test_empty_claims_does_not_raise_error(get_token_method):
417+
"""The credential should not raise error when claims parameter is empty or None"""
418+
419+
# Mock the CLI to avoid actual invocation
420+
with mock.patch("shutil.which", return_value="az"):
421+
with mock.patch(CHECK_OUTPUT, mock.Mock(return_value='{"accessToken": "token", "expiresOn": "2021-10-07 12:00:00.000000"}')):
422+
423+
if get_token_method == "get_token":
424+
# Test with None (default)
425+
token = AzureCliCredential().get_token("scope")
426+
assert token.token == "token"
427+
428+
# Test with empty string
429+
token = AzureCliCredential().get_token("scope", claims="")
430+
assert token.token == "token"
431+
432+
# Test with None explicitly
433+
token = AzureCliCredential().get_token("scope", claims=None)
434+
assert token.token == "token"
435+
else: # get_token_info
436+
# Test with None options
437+
token = AzureCliCredential().get_token_info("scope")
438+
assert token.token == "token"
439+
440+
# Test with empty options
441+
token = AzureCliCredential().get_token_info("scope", options={})
442+
assert token.token == "token"
443+
444+
# Test with None claims in options
445+
token = AzureCliCredential().get_token_info("scope", options={"claims": None})
446+
assert token.token == "token"
447+
448+
# Test with empty string claims in options
449+
token = AzureCliCredential().get_token_info("scope", options={"claims": ""})
450+
assert token.token == "token"

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,64 @@ async def fake_exec(*args, **_):
389389
kwargs = {"options": kwargs}
390390
token = await getattr(credential, get_token_method)("scope", **kwargs)
391391
assert token.token == expected_token
392+
393+
394+
@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
395+
async def test_claims_challenge_raises_error(get_token_method):
396+
"""The credential should raise CredentialUnavailableError when claims challenge is provided"""
397+
398+
claims = "test-claims-challenge"
399+
expected_message = f"Fail to get token, please run az login --claims-challenge {claims}"
400+
401+
if get_token_method == "get_token":
402+
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
403+
await AzureCliCredential().get_token("scope", claims=claims)
404+
else: # get_token_info
405+
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
406+
await AzureCliCredential().get_token_info("scope", options={"claims": claims})
407+
408+
409+
@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
410+
async def test_empty_claims_does_not_raise_error(get_token_method):
411+
"""The credential should not raise error when claims parameter is empty or None"""
412+
413+
successful_output = json.dumps({
414+
"expiresOn": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"),
415+
"accessToken": "access-token",
416+
"subscription": "subscription",
417+
"tenant": "tenant",
418+
"tokenType": "Bearer",
419+
})
420+
421+
# Mock the CLI to avoid actual invocation
422+
with mock.patch("shutil.which", return_value="az"):
423+
with mock.patch(SUBPROCESS_EXEC, mock_exec(successful_output)):
424+
425+
if get_token_method == "get_token":
426+
# Test with None (default)
427+
token = await AzureCliCredential().get_token("scope")
428+
assert token.token == "access-token"
429+
430+
# Test with empty string
431+
token = await AzureCliCredential().get_token("scope", claims="")
432+
assert token.token == "access-token"
433+
434+
# Test with None explicitly
435+
token = await AzureCliCredential().get_token("scope", claims=None)
436+
assert token.token == "access-token"
437+
else: # get_token_info
438+
# Test with None options
439+
token = await AzureCliCredential().get_token_info("scope")
440+
assert token.token == "access-token"
441+
442+
# Test with empty options
443+
token = await AzureCliCredential().get_token_info("scope", options={})
444+
assert token.token == "access-token"
445+
446+
# Test with None claims in options
447+
token = await AzureCliCredential().get_token_info("scope", options={"claims": None})
448+
assert token.token == "access-token"
449+
450+
# Test with empty string claims in options
451+
token = await AzureCliCredential().get_token_info("scope", options={"claims": ""})
452+
assert token.token == "access-token"

0 commit comments

Comments
 (0)