Skip to content

Commit

Permalink
Synchronous username/password auth (#6416)
Browse files Browse the repository at this point in the history
  • Loading branch information
chlowell authored Jul 30, 2019
1 parent 379c1a1 commit 7a0f5d3
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 49 deletions.
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

0 comments on commit 7a0f5d3

Please sign in to comment.