Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def close(self) -> None:
def get_token(
self,
*scopes: str,
claims: Optional[str] = None, # pylint:disable=unused-argument
claims: Optional[str] = None,
tenant_id: Optional[str] = None,
**kwargs: Any,
) -> AccessToken:
Expand All @@ -102,16 +102,20 @@ def get_token(
:param str scopes: desired scope for the access token. This credential allows only one scope per request.
For more information about scopes, see
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
:keyword str claims: not used by this credential; any value provided will be ignored.
:keyword str claims: additional claims required in the token. This credential does not support claims
challenges.
:keyword str tenant_id: optional tenant to include in the token request.

:return: An access token with the desired scopes.
:rtype: ~azure.core.credentials.AccessToken

:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
:raises ~azure.identity.CredentialUnavailableError: the credential was either unable to invoke the Azure CLI
or a claims challenge was provided.
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
receive an access token.
"""
if claims and claims.strip():
raise CredentialUnavailableError(f"Failed to get token. Run az login --claims-challenge {claims}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't fully form the required login command, for example --scopes could also be provided that should be included in the final login command.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the error message to include scopes in the suggested az login command. Now when scopes are provided, the error message will suggest: az login --claims-challenge {claims} --scope {first_scope}. When no scopes are provided, it will suggest: az login --claims-challenge {claims}. This provides users with a more complete and actionable command. Commit 2af955e.


options: TokenRequestOptions = {}
if tenant_id:
Expand All @@ -136,10 +140,14 @@ def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptions] =
:rtype: ~azure.core.credentials.AccessTokenInfo
:return: An AccessTokenInfo instance containing information about the token.

:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
:raises ~azure.identity.CredentialUnavailableError: the credential was either unable to invoke the Azure CLI
or a claims challenge was provided.
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
receive an access token.
"""
claims_value = options.get("claims") if options else None
if claims_value and claims_value.strip():
raise CredentialUnavailableError(f"Failed to get token. Run az login --claims-challenge {claims_value}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should be the format of encoding here? Right now, the credential receives the claims as a JSON string, and we end up passing this unquoted, which might not work for passing the argument through the shell environment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User just needs to run something like

az login --claims-challenge eyJhY2Nlc3NfdG9rZW4iOnsiYWNycyI6eyJlc3NlbnRpYWwiOnRydWUsInZhbHVlcyI6WyJwMSJdfX19

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation is correct. Azure claims challenges are typically JWT tokens that are base64-encoded (as shown in xiangyan99's example), which only contain alphanumeric characters plus +, /, and = - these don't require shell escaping or quotes. The format follows standard Azure CLI usage patterns.

return self._get_token_base(*scopes, options=options)

def _get_token_base(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def __init__(
async def get_token(
self,
*scopes: str,
claims: Optional[str] = None, # pylint:disable=unused-argument
claims: Optional[str] = None,
tenant_id: Optional[str] = None,
**kwargs: Any,
) -> AccessToken:
Expand All @@ -94,15 +94,20 @@ async def get_token(
:param str scopes: desired scope for the access token. This credential allows only one scope per request.
For more information about scopes, see
https://learn.microsoft.com/entra/identity-platform/scopes-oidc.
:keyword str claims: not used by this credential; any value provided will be ignored.
:keyword str claims: additional claims required in the token. This credential does not support claims
challenges.
:keyword str tenant_id: optional tenant to include in the token request.

:return: An access token with the desired scopes.
:rtype: ~azure.core.credentials.AccessToken
:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
:raises ~azure.identity.CredentialUnavailableError: the credential was either unable to invoke the Azure CLI
or a claims challenge was provided.
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
receive an access token.
"""
if claims and claims.strip():
raise CredentialUnavailableError(f"Failed to get token. Run az login --claims-challenge {claims}")

# only ProactorEventLoop supports subprocesses on Windows (and it isn't the default loop on Python < 3.8)
if sys.platform.startswith("win") and not isinstance(asyncio.get_event_loop(), asyncio.ProactorEventLoop):
return _SyncAzureCliCredential().get_token(*scopes, tenant_id=tenant_id, **kwargs)
Expand Down Expand Up @@ -130,10 +135,14 @@ async def get_token_info(self, *scopes: str, options: Optional[TokenRequestOptio
:rtype: ~azure.core.credentials.AccessTokenInfo
:return: An AccessTokenInfo instance containing information about the token.

:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
:raises ~azure.identity.CredentialUnavailableError: the credential was either unable to invoke the Azure CLI
or a claims challenge was provided.
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
receive an access token.
"""
claims_value = options.get("claims") if options else None
if claims_value and claims_value.strip():
raise CredentialUnavailableError(f"Failed to get token. Run az login --claims-challenge {claims_value}")
# only ProactorEventLoop supports subprocesses on Windows (and it isn't the default loop on Python < 3.8)
if sys.platform.startswith("win") and not isinstance(asyncio.get_event_loop(), asyncio.ProactorEventLoop):
return _SyncAzureCliCredential().get_token_info(*scopes, options=options)
Expand Down
63 changes: 63 additions & 0 deletions sdk/identity/azure-identity/tests/test_cli_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,66 @@ def fake_check_output(command_line, **_):
kwargs = {"options": kwargs}
token = getattr(credential, get_token_method)("scope", **kwargs)
assert token.token == expected_token


@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
def test_claims_challenge_raises_error(get_token_method):
"""The credential should raise CredentialUnavailableError when claims challenge is provided"""

claims = "test-claims-challenge"
expected_message = f"Failed to get token. Run az login --claims-challenge {claims}"

if get_token_method == "get_token":
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
AzureCliCredential().get_token("scope", claims=claims)
else: # get_token_info
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
AzureCliCredential().get_token_info("scope", options={"claims": claims})


@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
def test_empty_claims_does_not_raise_error(get_token_method):
"""The credential should not raise error when claims parameter is empty or None"""

# Mock the CLI to avoid actual invocation
with mock.patch("shutil.which", return_value="az"):
with mock.patch(
CHECK_OUTPUT, mock.Mock(return_value='{"accessToken": "token", "expiresOn": "2021-10-07 12:00:00.000000"}')
Copy link
Preview

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded datetime string '2021-10-07 12:00:00.000000' in the mock response should use a dynamic date or a more recent date to avoid potential issues with token expiration validation.

Suggested change
CHECK_OUTPUT, mock.Mock(return_value='{"accessToken": "token", "expiresOn": "2021-10-07 12:00:00.000000"}')
CHECK_OUTPUT, mock.Mock(return_value=json.dumps({"accessToken": "token", "expiresOn": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S.%f")}))

Copilot uses AI. Check for mistakes.

):

if get_token_method == "get_token":
# Test with None (default)
token = AzureCliCredential().get_token("scope")
assert token.token == "token"

# Test with empty string
token = AzureCliCredential().get_token("scope", claims="")
assert token.token == "token"

# Test with None explicitly
token = AzureCliCredential().get_token("scope", claims=None)
assert token.token == "token"

# Test with whitespace-only string
token = AzureCliCredential().get_token("scope", claims=" ")
assert token.token == "token"
else: # get_token_info
# Test with None options
token = AzureCliCredential().get_token_info("scope")
assert token.token == "token"

# Test with empty options
token = AzureCliCredential().get_token_info("scope", options={})
assert token.token == "token"

# Test with None claims in options
token = AzureCliCredential().get_token_info("scope", options={"claims": None})
assert token.token == "token"

# Test with empty string claims in options
token = AzureCliCredential().get_token_info("scope", options={"claims": ""})
assert token.token == "token"

# Test with whitespace-only claims in options
token = AzureCliCredential().get_token_info("scope", options={"claims": " "})
assert token.token == "token"
71 changes: 71 additions & 0 deletions sdk/identity/azure-identity/tests/test_cli_credential_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,74 @@ async def fake_exec(*args, **_):
kwargs = {"options": kwargs}
token = await getattr(credential, get_token_method)("scope", **kwargs)
assert token.token == expected_token


@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
async def test_claims_challenge_raises_error(get_token_method):
"""The credential should raise CredentialUnavailableError when claims challenge is provided"""

claims = "test-claims-challenge"
expected_message = f"Failed to get token. Run az login --claims-challenge {claims}"

if get_token_method == "get_token":
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
await AzureCliCredential().get_token("scope", claims=claims)
else: # get_token_info
with pytest.raises(CredentialUnavailableError, match=re.escape(expected_message)):
await AzureCliCredential().get_token_info("scope", options={"claims": claims})


@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
async def test_empty_claims_does_not_raise_error(get_token_method):
"""The credential should not raise error when claims parameter is empty or None"""

successful_output = json.dumps(
{
"expiresOn": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"),
Copy link
Preview

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using datetime.now() without importing datetime module from the datetime package. Should be 'from datetime import datetime' or use a fixed future date for consistency.

Suggested change
"expiresOn": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"),
"expiresOn": "2023-01-01 00:00:00.000000",

Copilot uses AI. Check for mistakes.

"accessToken": "access-token",
"subscription": "subscription",
"tenant": "tenant",
"tokenType": "Bearer",
}
)

# Mock the CLI to avoid actual invocation
with mock.patch("shutil.which", return_value="az"):
with mock.patch(SUBPROCESS_EXEC, mock_exec(successful_output)):

if get_token_method == "get_token":
# Test with None (default)
token = await AzureCliCredential().get_token("scope")
assert token.token == "access-token"

# Test with empty string
token = await AzureCliCredential().get_token("scope", claims="")
assert token.token == "access-token"

# Test with None explicitly
token = await AzureCliCredential().get_token("scope", claims=None)
assert token.token == "access-token"

# Test with whitespace-only string
token = await AzureCliCredential().get_token("scope", claims=" ")
assert token.token == "access-token"
else: # get_token_info
# Test with None options
token = await AzureCliCredential().get_token_info("scope")
assert token.token == "access-token"

# Test with empty options
token = await AzureCliCredential().get_token_info("scope", options={})
assert token.token == "access-token"

# Test with None claims in options
token = await AzureCliCredential().get_token_info("scope", options={"claims": None})
assert token.token == "access-token"

# Test with empty string claims in options
token = await AzureCliCredential().get_token_info("scope", options={"claims": ""})
assert token.token == "access-token"

# Test with whitespace-only claims in options
token = await AzureCliCredential().get_token_info("scope", options={"claims": " "})
assert token.token == "access-token"