-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Refactor AzureCliCredential claims challenge logic and add tenant support in error messages #42195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
a0105bb
1541bfd
37b031e
eb3eb89
1ced477
3f45fb2
ae796fc
e778f33
a9bd2a7
2af955e
30832b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
|
@@ -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}") | ||
|
||
options: TokenRequestOptions = {} | ||
if tenant_id: | ||
|
@@ -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}") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User just needs to run something like az login --claims-challenge eyJhY2Nlc3NfdG9rZW4iOnsiYWNycyI6eyJlc3NlbnRpYWwiOnRydWUsInZhbHVlcyI6WyJwMSJdfX19 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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"}') | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
): | ||||||
|
||||||
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" |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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"), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
"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" |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.