Skip to content

Commit a60d5d1

Browse files
authored
Add AzureApplicationCredential (Azure#19403)
1 parent 5df8693 commit a60d5d1

File tree

7 files changed

+381
-1
lines changed

7 files changed

+381
-1
lines changed

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
variable `AZURE_REGIONAL_AUTHORITY_NAME`. See `azure.identity.RegionalAuthority`
1111
for possible values.
1212
([#19301](https://github.com/Azure/azure-sdk-for-python/issues/19301))
13+
- `AzureApplicationCredential`, a default credential chain for applications
14+
deployed to Azure
15+
([#19309](https://github.com/Azure/azure-sdk-for-python/issues/19309))
1316

1417
## 1.7.0b1 (2021-06-08)
1518
Beginning with this release, this library requires Python 2.7 or 3.6+.

sdk/identity/azure-identity/azure/identity/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
from ._exceptions import AuthenticationRequiredError, CredentialUnavailableError
1010
from ._constants import AzureAuthorityHosts, KnownAuthorities
1111
from ._credentials import (
12-
AzureCliCredential,
1312
AuthorizationCodeCredential,
13+
AzureApplicationCredential,
14+
AzureCliCredential,
1415
AzurePowerShellCredential,
1516
CertificateCredential,
1617
ChainedTokenCredential,
@@ -31,6 +32,7 @@
3132
"AuthenticationRecord",
3233
"AuthenticationRequiredError",
3334
"AuthorizationCodeCredential",
35+
"AzureApplicationCredential",
3436
"AzureAuthorityHosts",
3537
"AzureCliCredential",
3638
"AzurePowerShellCredential",

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Copyright (c) Microsoft Corporation.
33
# Licensed under the MIT License.
44
# ------------------------------------
5+
from .application import AzureApplicationCredential
56
from .authorization_code import AuthorizationCodeCredential
67
from .azure_powershell import AzurePowerShellCredential
78
from .browser import InteractiveBrowserCredential
@@ -21,6 +22,7 @@
2122

2223
__all__ = [
2324
"AuthorizationCodeCredential",
25+
"AzureApplicationCredential",
2426
"AzureArcCredential",
2527
"AzureCliCredential",
2628
"AzurePowerShellCredential",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
import logging
6+
import os
7+
from typing import TYPE_CHECKING
8+
9+
from .chained import ChainedTokenCredential
10+
from .environment import EnvironmentCredential
11+
from .managed_identity import ManagedIdentityCredential
12+
from .._constants import EnvironmentVariables
13+
from .._internal import get_default_authority, normalize_authority
14+
15+
if TYPE_CHECKING:
16+
# pylint:disable=unused-import,ungrouped-imports
17+
from typing import Any
18+
from azure.core.credentials import AccessToken
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
23+
class AzureApplicationCredential(ChainedTokenCredential):
24+
"""A credential for Azure Active Directory applications.
25+
26+
This credential is designed for applications deployed to Azure (:class:`~azure.identity.DefaultAzureCredential` is
27+
better suited to local development). It authenticates service principals and managed identities.
28+
29+
For service principal authentication, set these environment variables to identify a principal:
30+
31+
- **AZURE_TENANT_ID**: ID of the service principal's tenant. Also called its "directory" ID.
32+
- **AZURE_CLIENT_ID**: the service principal's client ID
33+
34+
And one of these to authenticate that principal:
35+
36+
- **AZURE_CLIENT_SECRET**: one of the service principal's client secrets
37+
38+
**or**
39+
40+
- **AZURE_CLIENT_CERTIFICATE_PATH**: path to a PEM-encoded certificate file including the private key. The
41+
certificate must not be password-protected.
42+
43+
See `Azure CLI documentation <https://docs.microsoft.com/cli/azure/create-an-azure-service-principal-azure-cli>`_
44+
for more information about creating and managing service principals.
45+
46+
When this environment configuration is incomplete, the credential will attempt to authenticate a managed identity.
47+
See `Azure Active Directory documentation
48+
<https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview>`_ for an overview of
49+
managed identities.
50+
51+
:keyword str authority: Authority of an Azure Active Directory endpoint, for example "login.microsoftonline.com",
52+
the authority for Azure Public Cloud, which is the default when no value is given for this keyword argument or
53+
environment variable AZURE_AUTHORITY_HOST. :class:`~azure.identity.AzureAuthorityHosts` defines authorities for
54+
other clouds. Authority configuration applies only to service principal authentication.
55+
:keyword str managed_identity_client_id: The client ID of a user-assigned managed identity. Defaults to the value
56+
of the environment variable AZURE_CLIENT_ID, if any. If not specified, a system-assigned identity will be used.
57+
"""
58+
59+
def __init__(self, **kwargs):
60+
# type: (**Any) -> None
61+
authority = kwargs.pop("authority", None)
62+
authority = normalize_authority(authority) if authority else get_default_authority()
63+
super(AzureApplicationCredential, self).__init__(
64+
EnvironmentCredential(authority=authority, **kwargs),
65+
ManagedIdentityCredential(
66+
client_id=kwargs.pop(
67+
"managed_identity_client_id", os.environ.get(EnvironmentVariables.AZURE_CLIENT_ID)
68+
),
69+
**kwargs
70+
),
71+
)
72+
73+
def get_token(self, *scopes, **kwargs):
74+
# type: (*str, **Any) -> AccessToken
75+
"""Request an access token for `scopes`.
76+
77+
This method is called automatically by Azure SDK clients.
78+
79+
:param str scopes: desired scopes for the access token. This method requires at least one scope.
80+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The exception has a
81+
`message` attribute listing each authentication attempt and its error message.
82+
"""
83+
if self._successful_credential:
84+
token = self._successful_credential.get_token(*scopes, **kwargs)
85+
_LOGGER.info(
86+
"%s acquired a token from %s", self.__class__.__name__, self._successful_credential.__class__.__name__
87+
)
88+
return token
89+
90+
return super(AzureApplicationCredential, self).get_token(*scopes, **kwargs)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
import logging
6+
import os
7+
from typing import TYPE_CHECKING
8+
9+
from .chained import ChainedTokenCredential
10+
from .environment import EnvironmentCredential
11+
from .managed_identity import ManagedIdentityCredential
12+
from ..._constants import EnvironmentVariables
13+
from ..._internal import get_default_authority, normalize_authority
14+
15+
if TYPE_CHECKING:
16+
# pylint:disable=unused-import,ungrouped-imports
17+
from typing import Any
18+
from azure.core.credentials import AccessToken
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
23+
class AzureApplicationCredential(ChainedTokenCredential):
24+
"""A credential for Azure Active Directory applications.
25+
26+
This credential is designed for applications deployed to Azure (:class:`~azure.identity.aio.DefaultAzureCredential`
27+
is better suited to local development). It authenticates service principals and managed identities.
28+
29+
For service principal authentication, set these environment variables to identify a principal:
30+
31+
- **AZURE_TENANT_ID**: ID of the service principal's tenant. Also called its "directory" ID.
32+
- **AZURE_CLIENT_ID**: the service principal's client ID
33+
34+
And one of these to authenticate that principal:
35+
36+
- **AZURE_CLIENT_SECRET**: one of the service principal's client secrets
37+
38+
**or**
39+
40+
- **AZURE_CLIENT_CERTIFICATE_PATH**: path to a PEM-encoded certificate file including the private key. The
41+
certificate must not be password-protected.
42+
43+
See `Azure CLI documentation <https://docs.microsoft.com/cli/azure/create-an-azure-service-principal-azure-cli>`_
44+
for more information about creating and managing service principals.
45+
46+
When this environment configuration is incomplete, the credential will attempt to authenticate a managed identity.
47+
See `Azure Active Directory documentation
48+
<https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview>`_ for an overview of
49+
managed identities.
50+
51+
:keyword str authority: Authority of an Azure Active Directory endpoint, for example "login.microsoftonline.com",
52+
the authority for Azure Public Cloud, which is the default when no value is given for this keyword argument or
53+
environment variable AZURE_AUTHORITY_HOST. :class:`~azure.identity.AzureAuthorityHosts` defines authorities for
54+
other clouds. Authority configuration applies only to service principal authentication.
55+
:keyword str managed_identity_client_id: The client ID of a user-assigned managed identity. Defaults to the value
56+
of the environment variable AZURE_CLIENT_ID, if any. If not specified, a system-assigned identity will be used.
57+
"""
58+
59+
def __init__(self, **kwargs: "Any") -> None:
60+
authority = kwargs.pop("authority", None)
61+
authority = normalize_authority(authority) if authority else get_default_authority()
62+
super().__init__(
63+
EnvironmentCredential(authority=authority, **kwargs),
64+
ManagedIdentityCredential(
65+
client_id=kwargs.pop(
66+
"managed_identity_client_id", os.environ.get(EnvironmentVariables.AZURE_CLIENT_ID)
67+
),
68+
**kwargs
69+
),
70+
)
71+
72+
async def get_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken":
73+
"""Asynchronously request an access token for `scopes`.
74+
75+
This method is called automatically by Azure SDK clients.
76+
77+
:param str scopes: desired scopes for the access token. This method requires at least one scope.
78+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The exception has a
79+
`message` attribute listing each authentication attempt and its error message.
80+
"""
81+
if self._successful_credential:
82+
token = await self._successful_credential.get_token(*scopes, **kwargs)
83+
_LOGGER.info(
84+
"%s acquired a token from %s", self.__class__.__name__, self._successful_credential.__class__.__name__
85+
)
86+
return token
87+
88+
return await super().get_token(*scopes, **kwargs)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
import os
6+
7+
from azure.core.credentials import AccessToken
8+
from azure.identity import AzureApplicationCredential, CredentialUnavailableError
9+
from azure.identity._constants import EnvironmentVariables
10+
import pytest
11+
from six.moves.urllib_parse import urlparse
12+
13+
try:
14+
from unittest.mock import Mock, patch
15+
except ImportError: # python < 3.3
16+
from mock import Mock, patch # type: ignore
17+
18+
19+
def test_iterates_only_once():
20+
"""When a credential succeeds, AzureApplicationCredential should use that credential thereafter"""
21+
22+
expected_token = AccessToken("***", 42)
23+
unavailable_credential = Mock(get_token=Mock(side_effect=CredentialUnavailableError(message="...")))
24+
successful_credential = Mock(get_token=Mock(return_value=expected_token))
25+
26+
credential = AzureApplicationCredential()
27+
credential.credentials = [
28+
unavailable_credential,
29+
successful_credential,
30+
Mock(get_token=Mock(side_effect=Exception("iteration didn't stop after a credential provided a token"))),
31+
]
32+
33+
for n in range(3):
34+
token = credential.get_token("scope")
35+
assert token.token == expected_token.token
36+
assert unavailable_credential.get_token.call_count == 1
37+
assert successful_credential.get_token.call_count == n + 1
38+
39+
40+
@pytest.mark.parametrize("authority", ("localhost", "https://localhost"))
41+
def test_authority(authority):
42+
"""the credential should accept authority configuration by keyword argument or environment"""
43+
44+
parsed_authority = urlparse(authority)
45+
expected_netloc = parsed_authority.netloc or authority # "localhost" parses to netloc "", path "localhost"
46+
47+
def test_initialization(mock_credential, expect_argument):
48+
AzureApplicationCredential(authority=authority)
49+
assert mock_credential.call_count == 1
50+
51+
# N.B. if os.environ has been patched somewhere in the stack, that patch is in place here
52+
environment = dict(os.environ, **{EnvironmentVariables.AZURE_AUTHORITY_HOST: authority})
53+
with patch.dict(AzureApplicationCredential.__module__ + ".os.environ", environment, clear=True):
54+
AzureApplicationCredential()
55+
assert mock_credential.call_count == 2
56+
57+
for _, kwargs in mock_credential.call_args_list:
58+
if expect_argument:
59+
actual = urlparse(kwargs["authority"])
60+
assert actual.scheme == "https"
61+
assert actual.netloc == expected_netloc
62+
else:
63+
assert "authority" not in kwargs
64+
65+
# authority should be passed to EnvironmentCredential as a keyword argument
66+
environment = {var: "foo" for var in EnvironmentVariables.CLIENT_SECRET_VARS}
67+
with patch(AzureApplicationCredential.__module__ + ".EnvironmentCredential") as mock_credential:
68+
with patch.dict("os.environ", environment, clear=True):
69+
test_initialization(mock_credential, expect_argument=True)
70+
71+
# authority should not be passed to ManagedIdentityCredential
72+
with patch(AzureApplicationCredential.__module__ + ".ManagedIdentityCredential") as mock_credential:
73+
with patch.dict("os.environ", {EnvironmentVariables.MSI_ENDPOINT: "localhost"}, clear=True):
74+
test_initialization(mock_credential, expect_argument=False)
75+
76+
77+
def test_managed_identity_client_id():
78+
"""the credential should accept a user-assigned managed identity's client ID by kwarg or environment variable"""
79+
80+
expected_args = {"client_id": "the-client"}
81+
82+
with patch(AzureApplicationCredential.__module__ + ".ManagedIdentityCredential") as mock_credential:
83+
AzureApplicationCredential(managed_identity_client_id=expected_args["client_id"])
84+
mock_credential.assert_called_once_with(**expected_args)
85+
86+
# client id can also be specified in $AZURE_CLIENT_ID
87+
with patch.dict(os.environ, {EnvironmentVariables.AZURE_CLIENT_ID: expected_args["client_id"]}, clear=True):
88+
with patch(AzureApplicationCredential.__module__ + ".ManagedIdentityCredential") as mock_credential:
89+
AzureApplicationCredential()
90+
mock_credential.assert_called_once_with(**expected_args)
91+
92+
# keyword argument should override environment variable
93+
with patch.dict(
94+
os.environ, {EnvironmentVariables.AZURE_CLIENT_ID: "not-" + expected_args["client_id"]}, clear=True
95+
):
96+
with patch(AzureApplicationCredential.__module__ + ".ManagedIdentityCredential") as mock_credential:
97+
AzureApplicationCredential(managed_identity_client_id=expected_args["client_id"])
98+
mock_credential.assert_called_once_with(**expected_args)

0 commit comments

Comments
 (0)