Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 (2020-03-10)
- `DefaultAzureCredential` can now authenticate using the identity logged in to
the Azure CLI, unless explicitly disabled with a keyword argument:
`DefaultAzureCredential(exclude_cli_credential=True)`
([#10092](https://github.com/Azure/azure-sdk-for-python/pull/10092))


## 1.3.0 (2020-02-11)
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",
]
151 changes: 151 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,151 @@
# ------------------------------------
# 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 or a well-known install location, not the executing program's directory"""

path = os.environ.get("PATH")

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

# no system path; check well-known install locations
cli_path = "Microsoft SDKs\\Azure\\CLI2\\wbin"
Comment thread
lmazuel marked this conversation as resolved.
for directory in (os.environ.get("PROGRAMFILES(X86)"), os.environ.get("PROGRAMFILES")):
if directory:
path = os.path.join(directory, cli_path)
if os.path.exists(os.path.join(path, "az.cmd")):
return path

raise CredentialUnavailableError(message=CLI_NOT_FOUND)

# linux or mac
if path:
return path.split(":")[0]

# no system path; check well-known install locations
for path in ("/usr/bin", "/usr/local/bin"):
if os.path.exists(path + "/az"):
return path

raise CredentialUnavailableError(message=CLI_NOT_FOUND)


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"
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,87 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import asyncio
import sys

from azure.core.exceptions import ClientAuthenticationError
from .._credentials.base import AsyncCredentialBase
from ... import CredentialUnavailableError
from ..._credentials.azure_cli import (
AzureCliCredential as _SyncAzureCliCredential,
CLI_NOT_FOUND,
COMMAND_LINE,
get_safe_working_dir,
parse_token,
sanitize_output,
)
from ..._internal import _scopes_to_resource


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.
"""
# 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, **kwargs)

resource = _scopes_to_resource(*scopes)
output = await _run_command(COMMAND_LINE.format(resource))

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

async def close(self):
"""Calling this method is unnecessary"""


async def _run_command(command):
if sys.platform.startswith("win"):
args = ("cmd", "/c " + command)
else:
args = ("/bin/sh", "-c " + command)

working_directory = get_safe_working_dir()

try:
proc = await asyncio.create_subprocess_exec(
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=working_directory
)
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]))
raise error from ex

stdout, _ = await asyncio.wait_for(proc.communicate(), 10)
output = stdout.decode()

if proc.returncode == 0:
return output

if proc.returncode == 127 or output.startswith("'az' is not recognized"):
raise CredentialUnavailableError(CLI_NOT_FOUND)

message = sanitize_output(output) if output else "Failed to invoke Azure CLI"
raise ClientAuthenticationError(message=message)
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
2 changes: 1 addition & 1 deletion sdk/identity/azure-identity/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
author_email="azpysdkhelp@microsoft.com",
url="https://github.com/Azure/azure-sdk-for-python/tree/master/sdk/identity/azure-identity",
classifiers=[
"Development Status :: 5 - Production/Stable",
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7",
Expand Down
Loading