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
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 @@
ClientSecretCredential,
EnvironmentCredential,
ManagedIdentityCredential,
UsernamePasswordCredential,
)


Expand Down Expand Up @@ -35,4 +36,5 @@ def __init__(self, **kwargs):
"DefaultAzureCredential",
"EnvironmentCredential",
"ManagedIdentityCredential",
"UsernamePasswordCredential",
]
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from .msal_credentials import ConfidentialClientCredential
from .msal_transport_adapter import MsalTransportResponse, MsalTransportAdapter
from .msal_credentials import ConfidentialClientCredential, PublicClientCredential
from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse
Original file line number Diff line number Diff line change
Expand Up @@ -5,71 +5,76 @@
"""Credentials wrapping MSAL applications and delegating token acquisition and caching to them.
This entails monkeypatching MSAL's OAuth client with an adapter substituting an azure-core pipeline for Requests.
"""

import abc
import time

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

from .msal_transport_adapter import MsalTransportAdapter

try:
from typing import TYPE_CHECKING
except ImportError:
TYPE_CHECKING = False
ABC = abc.ABC
except AttributeError: # Python 2.7, abc exists, but not ABC
ABC = abc.ABCMeta("ABC", (object,), {"__slots__": ()}) # type: ignore

try:
from unittest import mock
except ImportError: # python < 3.3
import mock # type: ignore

try:
from typing import TYPE_CHECKING
except ImportError:
TYPE_CHECKING = False

if TYPE_CHECKING:
# pylint:disable=unused-import
from typing import Any, Mapping, Optional, Union
from typing import Any, Mapping, Optional, Type, Union

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

from .msal_transport_adapter import MsalTransportAdapter


class MsalCredential(object):
class MsalCredential(ABC):
"""Base class for credentials wrapping MSAL applications"""

def __init__(self, client_id, authority, app_class, client_credential=None, **kwargs):
# type: (str, str, msal.ClientApplication, Optional[Union[str, Mapping[str, str]]], Any) -> None
def __init__(self, client_id, authority, client_credential=None, **kwargs):
# type: (str, str, Optional[Union[str, Mapping[str, str]]], Any) -> None
self._authority = authority
self._client_credential = client_credential
self._client_id = client_id

self._adapter = kwargs.pop("msal_adapter", None) or MsalTransportAdapter(**kwargs)

# postpone creating the wrapped application because its initializer uses the network
self._app_class = app_class
self._msal_app = None # type: Optional[msal.ClientApplication]

@property
def _app(self):
@abc.abstractmethod
def get_token(self, *scopes):
# type: (str) -> AccessToken
pass

@abc.abstractmethod
def _get_app(self):
# type: () -> msal.ClientApplication
"""The wrapped MSAL application"""
pass

if not self._msal_app:
# MSAL application initializers use msal.authority to send AAD tenant discovery requests
with mock.patch("msal.authority.requests", self._adapter):
app = self._app_class(
client_id=self._client_id, client_credential=self._client_credential, authority=self._authority
)
def _create_app(self, cls):
# type: (Type[msal.ClientApplication]) -> msal.ClientApplication
"""Creates an MSAL application, patching msal.authority to use an azure-core pipeline during tenant discovery"""

# monkeypatch the app to replace requests.Session with MsalTransportAdapter
app.client.session = self._adapter
self._msal_app = app
# MSAL application initializers use msal.authority to send AAD tenant discovery requests
with mock.patch("msal.authority.requests", self._adapter):
app = cls(client_id=self._client_id, client_credential=self._client_credential, authority=self._authority)

return self._msal_app
# monkeypatch the app to replace requests.Session with MsalTransportAdapter
app.client.session = self._adapter

return app


class ConfidentialClientCredential(MsalCredential):
"""Wraps an MSAL ConfidentialClientApplication with the TokenCredential API"""

def __init__(self, **kwargs):
# type: (Any) -> None
super(ConfidentialClientCredential, self).__init__(app_class=msal.ConfidentialClientApplication, **kwargs)

def get_token(self, *scopes):
# type: (str) -> AccessToken

Expand All @@ -79,10 +84,37 @@ def get_token(self, *scopes):

# First try to get a cached access token or if a refresh token is cached, redeem it for an access token.
# Failing that, acquire a new token.
app = self._app # type: msal.ConfidentialClientApplication
app = self._get_app()
result = app.acquire_token_silent(scopes, account=None) or app.acquire_token_for_client(scopes)

if "access_token" not in result:
raise ClientAuthenticationError(message="authentication failed: {}".format(result.get("error_description")))

return AccessToken(result["access_token"], now + int(result["expires_in"]))

def _get_app(self):
# type: () -> msal.ConfidentialClientApplication
if not self._msal_app:
self._msal_app = self._create_app(msal.ConfidentialClientApplication)
return self._msal_app


class PublicClientCredential(MsalCredential):
"""Wraps an MSAL PublicClientApplication with the TokenCredential API"""

def __init__(self, **kwargs):
# type: (Any) -> None
super(PublicClientCredential, self).__init__(
authority="https://login.microsoftonline.com/" + kwargs.pop("tenant", "organizations"), **kwargs
)

@abc.abstractmethod
def get_token(self, *scopes):
# type: (str) -> AccessToken
pass

def _get_app(self):
# type: () -> msal.PublicClientApplication
if not self._msal_app:
self._msal_app = self._create_app(msal.PublicClientApplication)
return self._msal_app
4 changes: 4 additions & 0 deletions sdk/identity/azure-identity/azure/identity/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class EnvironmentVariables:
AZURE_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH"
CERT_VARS = (AZURE_CLIENT_ID, AZURE_CLIENT_CERTIFICATE_PATH, AZURE_TENANT_ID)

AZURE_USERNAME = "AZURE_USERNAME"
AZURE_PASSWORD = "AZURE_PASSWORD"
USERNAME_PASSWORD_VARS = (AZURE_CLIENT_ID, AZURE_USERNAME, AZURE_PASSWORD)

MSI_ENDPOINT = "MSI_ENDPOINT"
MSI_SECRET = "MSI_SECRET"

Expand Down
100 changes: 88 additions & 12 deletions sdk/identity/azure-identity/azure/identity/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Credentials for Azure SDK authentication.
"""
import os
import time

from azure.core import Configuration
from azure.core.credentials import AccessToken
Expand All @@ -14,6 +15,7 @@

from ._authn_client import AuthnClient
from ._base import ClientSecretCredentialBase, CertificateCredentialBase
from ._internal import PublicClientCredential
from ._managed_identity import ImdsCredential, MsiCredential
from .constants import Endpoints, EnvironmentVariables

Expand All @@ -26,6 +28,7 @@
# pylint:disable=unused-import
from typing import Any, Dict, Mapping, Optional, Union
from azure.core.credentials import TokenCredential
EnvironmentCredentialTypes = Union["CertificateCredential", "ClientSecretCredential", "UsernamePasswordCredential"]

# pylint:disable=too-few-public-methods

Expand Down Expand Up @@ -96,23 +99,29 @@ def get_token(self, *scopes):

class EnvironmentCredential:
"""
Authenticates as a service principal using a client ID/secret pair or a certificate,
depending on environment variable settings.

These environment variables are required:
Authenticates as a service principal using a client secret or a certificate, or as a user with a username and
password, depending on environment variable settings. Configuration is attempted in this order, using these
environment variables:

Service principal with secret:
- **AZURE_CLIENT_ID**: the service principal's client ID
- **AZURE_CLIENT_SECRET**: one of the service principal's client secrets
- **AZURE_TENANT_ID**: ID of the service principal's tenant. Also called its 'directory' ID.

Additionally, set **one** of these to configure client secret or certificate authentication:

- **AZURE_CLIENT_SECRET**: one of the service principal's client secrets
Service principal with certificate:
- **AZURE_CLIENT_ID**: the service principal's client ID
- **AZURE_CLIENT_CERTIFICATE_PATH**: path to a PEM-encoded certificate file including the private key
- **AZURE_TENANT_ID**: ID of the service principal's tenant. Also called its 'directory' ID.

User with username and password:
- **AZURE_CLIENT_ID**: the application's client ID
- **AZURE_USERNAME**: a username (usually an email address)
- **AZURE_PASSWORD**: that user's password
"""

def __init__(self, **kwargs):
# type: (Mapping[str, Any]) -> None
self._credential = None # type: Optional[Union[CertificateCredential, ClientSecretCredential]]
self._credential = None # type: Optional[EnvironmentCredentialTypes]

if all(os.environ.get(v) is not None for v in EnvironmentVariables.CLIENT_SECRET_VARS):
self._credential = ClientSecretCredential(
Expand All @@ -128,6 +137,14 @@ def __init__(self, **kwargs):
certificate_path=os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH],
**kwargs
)
elif all(os.environ.get(v) is not None for v in EnvironmentVariables.USERNAME_PASSWORD_VARS):
self._credential = UsernamePasswordCredential(
client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID],
username=os.environ[EnvironmentVariables.AZURE_USERNAME],
password=os.environ[EnvironmentVariables.AZURE_PASSWORD],
tenant=os.environ.get(EnvironmentVariables.AZURE_TENANT_ID), # optional for username/password auth
**kwargs
)

def get_token(self, *scopes):
# type (*str) -> AccessToken
Expand All @@ -139,10 +156,7 @@ def get_token(self, *scopes):
:raises: :class:`azure.core.exceptions.ClientAuthenticationError`
"""
if not self._credential:
message = "Missing environment settings. To authenticate with one of the service principal's client secrets, set {}. To authenticate with a certificate, set {}.".format(
", ".join(EnvironmentVariables.CLIENT_SECRET_VARS), ", ".join(EnvironmentVariables.CERT_VARS)
)
raise ClientAuthenticationError(message=message)
raise ClientAuthenticationError(message="Incomplete environment configuration.")
return self._credential.get_token(*scopes)


Expand Down Expand Up @@ -233,3 +247,65 @@ def _get_error_message(history):
else:
attempts.append(credential.__class__.__name__)
return "No valid token received. {}".format(". ".join(attempts))


class UsernamePasswordCredential(PublicClientCredential):
"""
Authenticates a user with a username and password. In general, Microsoft doesn't recommend this kind of
authentication, because it's less secure than other authentication flows.

Authentication with this credential is not interactive, so it is **not compatible with any form of
multi-factor authentication or consent prompting**. The application must already have the user's consent.

This credential can only authenticate work and school accounts; Microsoft accounts are not supported.
See this document for more information about account types:
https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/sign-up-organization

:param str client_id: the application's client ID
:param str username: the user's username (usually an email address)
:param str password: the user's password

**Keyword arguments:**

*tenant (str)* - a tenant ID or a domain associated with a tenant. If not provided, the credential defaults to the
'organizations' tenant.
"""

def __init__(self, client_id, username, password, **kwargs):
# type: (str, str, str, Any) -> None
super(UsernamePasswordCredential, self).__init__(client_id=client_id, **kwargs)
self._username = username
self._password = password

def get_token(self, *scopes):
# type (*str) -> AccessToken
"""
Request an access token for `scopes`.

:param str scopes: desired scopes for the token
:rtype: :class:`azure.core.credentials.AccessToken`
:raises: :class:`azure.core.exceptions.ClientAuthenticationError`
"""

# MSAL requires scopes be a list
scopes = list(scopes) # type: ignore
now = int(time.time())

app = self._get_app()
accounts = app.get_accounts(username=self._username)
result = None
for account in accounts:
result = app.acquire_token_silent(scopes, account=account)
if result:
break

if not result:
# cache miss -> request a new token
result = app.acquire_token_by_username_password(
username=self._username, password=self._password, scopes=scopes
)

if "access_token" not in result:
raise ClientAuthenticationError(message="authentication failed: {}".format(result.get("error_description")))

return AccessToken(result["access_token"], now + int(result["expires_in"]))
31 changes: 31 additions & 0 deletions sdk/identity/azure-identity/tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
EnvironmentCredential,
ManagedIdentityCredential,
ChainedTokenCredential,
UsernamePasswordCredential,
)
from azure.identity._managed_identity import ImdsCredential
from azure.identity.constants import EnvironmentVariables
Expand Down Expand Up @@ -239,3 +240,33 @@ def test_imds_credential_retries():

def test_default_credential():
DefaultAzureCredential()


def test_username_password_credential():
expected_token = "access-token"
transport = validating_transport(
requests=[Request()] * 2, # not validating requests because they're formed by MSAL
responses=[
# expecting tenant discovery then a token request
mock_response(json_payload={"authorization_endpoint": "https://a/b", "token_endpoint": "https://a/b"}),
mock_response(
json_payload={
"access_token": expected_token,
"expires_in": 42,
"token_type": "Bearer",
"ext_expires_in": 42,
}
),
],
)

credential = UsernamePasswordCredential(
client_id="some-guid",
username="user@azure",
password="secret_password",
transport=transport,
instance_discovery=False, # kwargs are passed to MSAL; this one prevents an AAD verification request
)

token = credential.get_token("scope")
assert token.token == expected_token
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from azure.keyvault.keys._shared import ChallengeAuthPolicy, HttpChallenge, HttpChallengeCache
import pytest

from helpers import mock_response, Request, validating_transport
from keys_helpers import mock_response, Request, validating_transport


def test_challenge_cache():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from azure.keyvault.keys._shared import AsyncChallengeAuthPolicy, HttpChallenge, HttpChallengeCache
import pytest

from helpers import async_validating_transport, mock_response, Request
from keys_helpers import async_validating_transport, mock_response, Request


@pytest.mark.asyncio
Expand Down