Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
6 changes: 5 additions & 1 deletion sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Release History

## 1.3.1 (Unreleased)
## 1.4.0b1 (Unreleased)
- Added `AzureCliCredential`, which authenticates with the identity logged in
to the Azure CLI. This credential is part of `DefaultAzureCredential` by
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please sync with rest of lang devs to ensure we are on the same page across langs on whether or not DAC has CLICred by default.

I highly prefer it to be enabled by default so developer doesn't have to do anything to configure it.

default, but can be excluded with a keyword argument:
`DefaultAzureCredential(exclude_cli_credential=True)`


## 1.3.0 (2020-02-11)
Expand Down
2 changes: 2 additions & 0 deletions sdk/identity/azure-identity/azure/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ._constants import KnownAuthorities
from ._credentials import (
AuthorizationCodeCredential,
AzureCliCredential,
CertificateCredential,
ChainedTokenCredential,
ClientSecretCredential,
Expand All @@ -23,6 +24,7 @@

__all__ = [
"AuthorizationCodeCredential",
"AzureCliCredential",
Comment thread
schaabs marked this conversation as resolved.
Outdated
"CertificateCredential",
"ChainedTokenCredential",
"ClientSecretCredential",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .environment import EnvironmentCredential
from .managed_identity import ManagedIdentityCredential
from .shared_cache import SharedTokenCacheCredential
from .azure_cli import AzureCliCredential
from .user import DeviceCodeCredential, UsernamePasswordCredential


Expand All @@ -24,5 +25,6 @@
"InteractiveBrowserCredential",
"ManagedIdentityCredential",
"SharedTokenCacheCredential",
"AzureCliCredential",
"UsernamePasswordCredential",
]
129 changes: 129 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from datetime import datetime
import json
import os
import platform
import re
import sys
from typing import TYPE_CHECKING

import subprocess

from azure.core.credentials import AccessToken
from azure.core.exceptions import ClientAuthenticationError

from .. import CredentialUnavailableError
from .._internal import _scopes_to_resource

if TYPE_CHECKING:
# pylint:disable=ungrouped-imports
from typing import Any

CLI_NOT_FOUND = "Azure CLI not found on path"
COMMAND_LINE = "az account get-access-token --output json --resource {}"

# CLI's "expiresOn" is naive, so we use this naive datetime for the epoch to calculate expires_on in UTC
EPOCH = datetime.fromtimestamp(0)


class AzureCliCredential(object):
"""Authenticates by requesting a token from the Azure CLI.

This requires previously logging in to Azure via "az login", and will use the CLI's currently logged in identity.
"""

def get_token(self, *scopes, **kwargs): # pylint:disable=no-self-use,unused-argument
# type: (*str, **Any) -> AccessToken
"""Request an access token for `scopes`.

.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.

Only one scope is supported per request. This credential won't cache tokens. Every call invokes the Azure CLI.

:param str scopes: desired scopes for the token. Only **one** scope is supported per call.
:rtype: :class:`azure.core.credentials.AccessToken`

:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
receive an access token.
"""

resource = _scopes_to_resource(*scopes)
output, error = _run_command(COMMAND_LINE.format(resource))
if error:
raise error

token = parse_token(output)
if not token:
sanitized_output = sanitize_output(output)
raise ClientAuthenticationError(message="Unexpected output from Azure CLI: '{}'".format(sanitized_output))

return token


def parse_token(output):
"""Parse output of 'az account get-access-token' to an AccessToken.

In particular, convert the CLI's "expiresOn" value, the string representation of a naive datetime, to epoch seconds.
"""
try:
token = json.loads(output)
parsed_expires_on = datetime.strptime(token["expiresOn"], "%Y-%m-%d %H:%M:%S.%f")

# calculate seconds since the epoch; parsed_expires_on and EPOCH are naive
expires_on = (parsed_expires_on - EPOCH).total_seconds()

return AccessToken(token["accessToken"], int(expires_on))
except (KeyError, ValueError):
return None


def get_safe_working_dir():
"""Invoke 'az' from a directory on $PATH to get 'az' from the path, not the executing program's directory"""

path = os.environ["PATH"]
if sys.platform.startswith("win"):
return path.split(";")[0]
return path.split(":")[0]


def sanitize_output(output):
"""Redact access tokens from CLI output to prevent error messages revealing them"""
return re.sub(r"\"accessToken\": \"(.*?)(\"|$)", "****", output)


def _run_command(command):
if sys.platform.startswith("win"):
args = ["cmd", "/c", command]
else:
args = ["/bin/sh", "-c", command]
try:
working_directory = get_safe_working_dir()

kwargs = {"stderr": subprocess.STDOUT, "cwd": working_directory, "universal_newlines": True}
if platform.python_version() >= "3.3":
kwargs["timeout"] = 10

output = subprocess.check_output(args, **kwargs)
return output, None
except subprocess.CalledProcessError as ex:
# non-zero return from shell
if ex.returncode == 127 or ex.output.startswith("'az' is not recognized"):
error = CredentialUnavailableError(message=CLI_NOT_FOUND)
else:
# return code is from the CLI -> propagate its output
if ex.output:
message = sanitize_output(ex.output)
else:
message = "Failed to invoke Azure CLI"
error = ClientAuthenticationError(message=message)
except OSError as ex:
# failed to execute 'cmd' or '/bin/sh'; CLI may or may not be installed
error = CredentialUnavailableError(message="Failed to execute '{}'".format(args[0]))
except Exception as ex: # pylint:disable=broad-except
error = ex

return None, error
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from .environment import EnvironmentCredential
from .managed_identity import ManagedIdentityCredential
from .shared_cache import SharedTokenCacheCredential
from .azure_cli import AzureCliCredential


try:
from typing import TYPE_CHECKING
Expand All @@ -38,12 +40,14 @@ class DefaultAzureCredential(ChainedTokenCredential):
3. On Windows only: a user who has signed in with a Microsoft application, such as Visual Studio. If multiple
identities are in the cache, then the value of the environment variable ``AZURE_USERNAME`` is used to select
which identity to use. See :class:`~azure.identity.SharedTokenCacheCredential` for more details.
4. An Azure CLI access token. See :class:`~azure.identity.AzureCliCredential` for more details.

This default behavior is configurable with keyword arguments.

:keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com',
the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities`
defines authorities for other clouds. Managed identities ignore this because they reside in a single cloud.
:keyword bool exclude_cli_credential: Whether to exclude the Azure CLI from the credential. Defaults to **False**.
:keyword bool exclude_environment_credential: Whether to exclude a service principal configured by environment
variables from the credential. Defaults to **False**.
:keyword bool exclude_managed_identity_credential: Whether to exclude managed identity from the credential.
Expand All @@ -69,6 +73,7 @@ def __init__(self, **kwargs):
exclude_environment_credential = kwargs.pop("exclude_environment_credential", False)
exclude_managed_identity_credential = kwargs.pop("exclude_managed_identity_credential", False)
exclude_shared_token_cache_credential = kwargs.pop("exclude_shared_token_cache_credential", False)
exclude_cli_credential = kwargs.pop("exclude_cli_credential", False)
exclude_interactive_browser_credential = kwargs.pop("exclude_interactive_browser_credential", True)

credentials = []
Expand All @@ -86,6 +91,8 @@ def __init__(self, **kwargs):
except Exception as ex: # pylint:disable=broad-except
# transitive dependency pywin32 doesn't support 3.8 (https://github.com/mhammond/pywin32/issues/1431)
_LOGGER.info("Shared token cache is unavailable: '%s'", ex)
if not exclude_cli_credential:
credentials.append(AzureCliCredential())
if not exclude_interactive_browser_credential:
credentials.append(InteractiveBrowserCredential())

Expand Down
14 changes: 14 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@
from .msal_credentials import ConfidentialClientCredential, PublicClientCredential
from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse


def _scopes_to_resource(*scopes):
"""Convert an AADv2 scope to an AADv1 resource"""

if len(scopes) != 1:
raise ValueError("This credential supports only one scope per token request")

resource = scopes[0]
if resource.endswith("/.default"):
resource = resource[: -len("/.default")]

return resource


__all__ = [
"AadClient",
"AadClientBase",
Expand Down
2 changes: 1 addition & 1 deletion sdk/identity/azure-identity/azure/identity/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
VERSION = "1.3.1"
VERSION = "1.4.0b1"
2 changes: 2 additions & 0 deletions sdk/identity/azure-identity/azure/identity/aio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from ._credentials import (
AuthorizationCodeCredential,
AzureCliCredential,
CertificateCredential,
ChainedTokenCredential,
ClientSecretCredential,
Expand All @@ -18,6 +19,7 @@

__all__ = [
"AuthorizationCodeCredential",
"AzureCliCredential",
"CertificateCredential",
"ClientSecretCredential",
"DefaultAzureCredential",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
from .managed_identity import ManagedIdentityCredential
from .client_credential import CertificateCredential, ClientSecretCredential
from .shared_cache import SharedTokenCacheCredential
from .azure_cli import AzureCliCredential


__all__ = [
"AuthorizationCodeCredential",
"AzureCliCredential",
"CertificateCredential",
"ChainedTokenCredential",
"ClientSecretCredential",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from .._credentials.base import AsyncCredentialBase
from ..._credentials import AzureCliCredential as _SyncAzureCliCredential


class AzureCliCredential(AsyncCredentialBase):
"""Authenticates by requesting a token from the Azure CLI.

This requires previously logging in to Azure via "az login", and will use the CLI's currently logged in identity.
"""

async def get_token(self, *scopes, **kwargs):
"""Request an access token for `scopes`.

.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.

Only one scope is supported per request. This credential won't cache tokens. Every call invokes the Azure CLI.

:param str scopes: desired scopes for the token. Only **one** scope is supported per call.
:rtype: :class:`azure.core.credentials.AccessToken`

:raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI.
:raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't
receive an access token.
"""
return _SyncAzureCliCredential().get_token(*scopes, **kwargs)

async def close(self):
return
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from azure.core.exceptions import ClientAuthenticationError

from ..._constants import EnvironmentVariables, KnownAuthorities
from .azure_cli import AzureCliCredential
from .chained import ChainedTokenCredential
from .environment import EnvironmentCredential
from .managed_identity import ManagedIdentityCredential
Expand All @@ -32,12 +33,14 @@ class DefaultAzureCredential(ChainedTokenCredential):
3. On Windows only: a user who has signed in with a Microsoft application, such as Visual Studio. If multiple
identities are in the cache, then the value of the environment variable ``AZURE_USERNAME`` is used to select
which identity to use. See :class:`~azure.identity.aio.SharedTokenCacheCredential` for more details.
4. An Azure CLI access token. See :class:`~azure.identity.aio.AzureCliCredential` for more details.

This default behavior is configurable with keyword arguments.

:keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com',
the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities`
defines authorities for other clouds. Managed identities ignore this because they reside in a single cloud.
:keyword bool exclude_cli_credential: Whether to exclude the Azure CLI from the credential. Defaults to **False**.
:keyword bool exclude_environment_credential: Whether to exclude a service principal configured by environment
variables from the credential. Defaults to **False**.
:keyword bool exclude_managed_identity_credential: Whether to exclude managed identity from the credential.
Expand All @@ -58,6 +61,7 @@ def __init__(self, **kwargs):
"shared_cache_tenant_id", os.environ.get(EnvironmentVariables.AZURE_TENANT_ID)
)

exclude_cli_credential = kwargs.pop("exclude_cli_credential", False)
exclude_environment_credential = kwargs.pop("exclude_environment_credential", False)
exclude_managed_identity_credential = kwargs.pop("exclude_managed_identity_credential", False)
exclude_shared_token_cache_credential = kwargs.pop("exclude_shared_token_cache_credential", False)
Expand All @@ -77,6 +81,8 @@ def __init__(self, **kwargs):
except Exception as ex: # pylint:disable=broad-except
# transitive dependency pywin32 doesn't support 3.8 (https://github.com/mhammond/pywin32/issues/1431)
_LOGGER.info("Shared token cache is unavailable: '%s'", ex)
if not exclude_cli_credential:
credentials.append(AzureCliCredential())

super().__init__(*credentials)

Expand Down
Loading