Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 3 additions & 1 deletion sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
### Features Added
- `CertificateCredential` accepts certificates in PKCS12 format
([#13540](https://github.com/Azure/azure-sdk-for-python/issues/13540))
- `OnBehalfOfCredential` supports the on-behalf-of authentication flow for
accessing resources on behalf of users
([#19308](https://github.com/Azure/azure-sdk-for-python/issues/19308))

### Breaking Changes

Expand All @@ -17,7 +20,6 @@
([#18798](https://github.com/Azure/azure-sdk-for-python/issues/18798))



## 1.6.1 (2021-08-19)

### Other Changes
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 @@ -21,6 +21,7 @@
EnvironmentCredential,
InteractiveBrowserCredential,
ManagedIdentityCredential,
OnBehalfOfCredential,
SharedTokenCacheCredential,
UsernamePasswordCredential,
VisualStudioCodeCredential,
Expand All @@ -45,6 +46,7 @@
"EnvironmentCredential",
"InteractiveBrowserCredential",
"KnownAuthorities",
"OnBehalfOfCredential",
"RegionalAuthority",
"ManagedIdentityCredential",
"SharedTokenCacheCredential",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .default import DefaultAzureCredential
from .environment import EnvironmentCredential
from .managed_identity import ManagedIdentityCredential
from .on_behalf_of import OnBehalfOfCredential
from .shared_cache import SharedTokenCacheCredential
from .azure_cli import AzureCliCredential
from .device_code import DeviceCodeCredential
Expand All @@ -32,6 +33,7 @@
"EnvironmentCredential",
"InteractiveBrowserCredential",
"ManagedIdentityCredential",
"OnBehalfOfCredential",
"SharedTokenCacheCredential",
"AzureCliCredential",
"UsernamePasswordCredential",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import time
from typing import TYPE_CHECKING

import msal

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

from .._internal.decorators import log_get_token
from .._internal.msal_credentials import MsalCredential

if TYPE_CHECKING:
from typing import Any


class OnBehalfOfCredential(MsalCredential):
"""Authenticates a service principal via the on-behalf-of flow.

This flow is typically used by middle-tier services that authorize requests to other services with a delegated
user identity. Because this is not an interactive authentication flow, an application using it must have admin
consent for any delegated permissions before requesting tokens for them. See `Azure Active Directory documentation
<https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow>`_ for a more detailed
description of the on-behalf-of flow.

:param str tenant_id: ID of the service principal's tenant. Also called its "directory" ID.
:param str client_id: the service principal's client ID
:param str client_secret: one of the service principal's client secrets
:param str user_assertion: the access token the credential will use as the user assertion when requesting
on-behalf-of tokens

:keyword bool allow_multitenant_authentication: when True, enables the credential to acquire tokens from any tenant

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.

If allow_multitenant_authentication is true, is tenant_id still required?

@chlowell chlowell Aug 30, 2021

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, if only to identify a tenant the service principal is registered in.

the application is registered in. When False, which is the default, the credential will acquire tokens only
from the tenant specified by **tenant_id**.
: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.AzureAuthorityHosts`
defines authorities for other clouds.
"""

def __init__(self, tenant_id, client_id, client_secret, user_assertion, **kwargs):
# type: (str, str, str, str, **Any) -> None
super(OnBehalfOfCredential, self).__init__(client_id, client_secret, tenant_id=tenant_id, **kwargs)
self._assertion = user_assertion

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

This method is called automatically by Azure SDK clients.

:param str scopes: desired scope for the access token

:rtype: :class:`azure.core.credentials.AccessToken`
"""
if not scopes:
raise ValueError('"get_token" requires at least one scope')

app = self._get_app(**kwargs) # type: msal.ConfidentialClientApplication
request_time = int(time.time())
result = app.acquire_token_on_behalf_of(self._assertion, list(scopes), claims_challenge=kwargs.get("claims"))
if "access_token" not in result:
message = "Authentication failed: {}".format(result.get("error_description") or result.get("error"))
response = self._client.get_error_response(result)
raise ClientAuthenticationError(message=message, response=response)

return AccessToken(result["access_token"], request_time + int(result["expires_in"]))
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ def obtain_token_by_refresh_token(self, scopes, refresh_token, **kwargs):
response = self._pipeline.run(request, stream=False, retry_on_methods=self._POST, **kwargs)
return self._process_response(response, now)

def obtain_token_on_behalf_of(self, scopes, secret, user_assertion, **kwargs):
# type: (Iterable[str], str, str, **Any) -> AccessToken
# no need for an implementation, non-async OnBehalfOfCredential acquires tokens through MSAL
raise NotImplementedError()

# pylint:disable=no-self-use
def _build_pipeline(self, **kwargs):
# type: (**Any) -> Pipeline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ def obtain_token_by_client_secret(self, scopes, secret, **kwargs):
def obtain_token_by_refresh_token(self, scopes, refresh_token, **kwargs):
pass

@abc.abstractmethod
def obtain_token_on_behalf_of(self, scopes, secret, user_assertion, **kwargs):
pass

@abc.abstractmethod
def _build_pipeline(self, **kwargs):
pass
Expand Down Expand Up @@ -219,6 +223,19 @@ def _get_client_secret_request(self, scopes, secret, **kwargs):
request = self._post(data, **kwargs)
return request

def _get_on_behalf_of_request(self, scopes, secret, user_assertion, **kwargs):
# type: (Iterable[str], str, str, **Any) -> HttpRequest
data = {
"assertion": user_assertion,
"client_id": self._client_id,
"client_secret": secret,
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"requested_token_use": "on_behalf_of",
"scope": " ".join(scopes),
}
request = self._post(data, **kwargs)
return request

def _get_refresh_token_request(self, scopes, refresh_token, **kwargs):
# type: (Iterable[str], str, **Any) -> HttpRequest
data = {
Expand Down
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 @@ -15,6 +15,7 @@
DefaultAzureCredential,
EnvironmentCredential,
ManagedIdentityCredential,
OnBehalfOfCredential,
SharedTokenCacheCredential,
VisualStudioCodeCredential,
)
Expand All @@ -30,6 +31,7 @@
"DefaultAzureCredential",
"EnvironmentCredential",
"ManagedIdentityCredential",
"OnBehalfOfCredential",
"ChainedTokenCredential",
"SharedTokenCacheCredential",
"VisualStudioCodeCredential",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .default import DefaultAzureCredential
from .environment import EnvironmentCredential
from .managed_identity import ManagedIdentityCredential
from .on_behalf_of import OnBehalfOfCredential
from .certificate import CertificateCredential
from .client_secret import ClientSecretCredential
from .shared_cache import SharedTokenCacheCredential
Expand All @@ -27,6 +28,7 @@
"DefaultAzureCredential",
"EnvironmentCredential",
"ManagedIdentityCredential",
"OnBehalfOfCredential",
"SharedTokenCacheCredential",
"VisualStudioCodeCredential",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import logging
from typing import TYPE_CHECKING

from azure.core.exceptions import ClientAuthenticationError

from .._internal import AadClient
from .._internal.decorators import log_get_token_async
from ..._internal import validate_tenant_id

if TYPE_CHECKING:
from typing import Any
from azure.core.credentials import AccessToken

_LOGGER = logging.getLogger(__name__)


class OnBehalfOfCredential:

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.

Do we want to inherit from abc.ABC?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think so. Should this class have an abstract method?

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.

Sorry. I meant AsyncContextManager. I need some coffee. :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right you are. I needed coffee when I wrote this 💤

"""Authenticates a service principal via the on-behalf-of flow.

This flow is typically used by middle-tier services that authorize requests to other services with a delegated
user identity. Because this is not an interactive authentication flow, an application using it must have admin
consent for any delegated permissions before requesting tokens for them. See `Azure Active Directory documentation
<https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow>`_ for a more detailed
description of the on-behalf-of flow.

:param str tenant_id: ID of the service principal's tenant. Also called its "directory" ID.
:param str client_id: the service principal's client ID
:param str client_secret: one of the service principal's client secrets
:param str user_assertion: the access token the credential will use as the user assertion when requesting
on-behalf-of tokens

:keyword bool allow_multitenant_authentication: when True, enables the credential to acquire tokens from any tenant
the application is registered in. When False, which is the default, the credential will acquire tokens only
from the tenant specified by **tenant_id**.
: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.AzureAuthorityHosts`
defines authorities for other clouds.
"""

def __init__(
self, tenant_id: str, client_id: str, client_secret: str, user_assertion: str, **kwargs: "Any"
) -> None:
validate_tenant_id(tenant_id)

# note AadClient handles "allow_multitenant_authentication", "authority", and any pipeline kwargs
self._client = AadClient(tenant_id, client_id, **kwargs)
self._assertion = user_assertion
self._secret = client_secret

@log_get_token_async
async def get_token(self, *scopes: "Any", **kwargs: "Any") -> "AccessToken":
"""Asynchronously request an access token for `scopes`.

This method is called automatically by Azure SDK clients.

:param str scopes: desired scope for the access token

:rtype: :class:`azure.core.credentials.AccessToken`
"""
if not scopes:
raise ValueError('"get_token" requires at least one scope')

token = self._client.get_cached_access_token(scopes, **kwargs)
if not token:
# Note we assume the cache has tokens for one user only. That's okay because each instance of this class is
# locked to a single user (assertion). This assumption will become unsafe if this class allows applications
# to change an instance's assertion.
refresh_tokens = self._client.get_cached_refresh_tokens(scopes)
if len(refresh_tokens) == 1: # there should be only one
try:
refresh_token = refresh_tokens[0]["secret"]
token = await self._client.obtain_token_by_refresh_token(scopes, refresh_token, **kwargs)
except ClientAuthenticationError as ex:
_LOGGER.debug("silent authentication failed: %s", ex, exc_info=True)
except (IndexError, KeyError, TypeError) as ex:
# this is purely defensive, hasn't been observed in practice
_LOGGER.debug("silent authentication failed due to malformed refresh token: %s", ex, exc_info=True)

if not token:
# we don't have a refresh token, or silent auth failed: acquire a new token from the assertion
token = await self._client.obtain_token_on_behalf_of(scopes, self._secret, self._assertion, **kwargs)

return token
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ async def obtain_token_by_refresh_token(
response = await self._pipeline.run(request, retry_on_methods=self._POST, **kwargs)
return self._process_response(response, now)

async def obtain_token_on_behalf_of(
self, scopes: "Iterable[str]", secret: str, user_assertion: str, **kwargs: "Any"
) -> "AccessToken":
request = self._get_on_behalf_of_request(scopes=scopes, secret=secret, user_assertion=user_assertion, **kwargs)
now = int(time.time())
response = await self._pipeline.run(request, retry_on_methods=self._POST, **kwargs)
return self._process_response(response, now)

# pylint:disable=no-self-use
def _build_pipeline(self, **kwargs: "Any") -> "AsyncPipeline":
return build_async_pipeline(**kwargs)
1 change: 1 addition & 0 deletions sdk/identity/azure-identity/dev_requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
../../core/azure-core
aiohttp>=3.0; python_version >= '3.5'
azure-mgmt-resource>=19.0.0
mock;python_version<"3.3"
typing_extensions>=3.7.2
-e ../../../tools/azure-sdk-tools
Expand Down
37 changes: 0 additions & 37 deletions sdk/identity/azure-identity/samples/custom_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,9 @@
# ------------------------------------
"""Demonstrates custom credential implementation"""

import time
from typing import TYPE_CHECKING

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

if TYPE_CHECKING:
from typing import Any, Union
Expand All @@ -36,36 +32,3 @@ def get_token(self, *scopes, **kwargs):
"""get_token is the only method a credential must implement"""

return self._token


class OnBehalfOfCredential(object):
"""Authenticates via the On-Behalf-Of flow using MSAL for Python

A future version of azure-identity will include a credential supporting the On-Behalf-Of flow. Until then,
applications needing to authenticate through that flow can use a custom credential like this one.
"""

def __init__(self, tenant_id, client_id, client_secret, user_access_token):
# type: (str, str, str, str) -> None
self._confidential_client = msal.ConfidentialClientApplication(
client_id=client_id,
client_credential=client_secret,
authority="https://{}/{}".format(AzureAuthorityHosts.AZURE_PUBLIC_CLOUD, tenant_id)
)
self._user_token = user_access_token

def get_token(self, *scopes, **kwargs):
# type: (*str, **Any) -> AccessToken
"""get_token is the only method a credential must implement"""

now = int(time.time())
result = self._confidential_client.acquire_token_on_behalf_of(
user_assertion=self._user_token, scopes=list(scopes)
)

if result and "access_token" in result and "expires_in" in result:
return AccessToken(result["access_token"], now + int(result["expires_in"]))

raise ClientAuthenticationError(
message="Authentication failed: {}".format(result.get("error_description") or result.get("error"))
)
3 changes: 3 additions & 0 deletions sdk/identity/azure-identity/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
import mock # type: ignore


FAKE_CLIENT_ID = "fake-client-id"


def build_id_token(
iss="issuer",
sub="subject",
Expand Down
4 changes: 2 additions & 2 deletions sdk/identity/azure-identity/tests/recorded_test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from devtools_testutils.azure_testcase import AzureTestCase
import pytest

from recording_processors import RecordingRedactor
from recording_processors import IdTokenProcessor, RecordingRedactor

PLAYBACK_CLIENT_ID = "client-id"

Expand All @@ -19,7 +19,7 @@ def __init__(self, *args, **kwargs):
super(RecordedTestCase, self).__init__(
*args,
recording_processors=[RecordingRedactor(), scrubber],
replay_processors=[RequestUrlNormalizer()],
replay_processors=[IdTokenProcessor(), RequestUrlNormalizer()],
**kwargs
)
self.scrubber = scrubber
Expand Down
Loading