Skip to content
Closed
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
4 changes: 4 additions & 0 deletions sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
## 1.7.0b3 (Unreleased)

### Features Added
- `OnBehalfOfCredential` supports the on-behalf-of authentication flow for
accessing resources on behalf of users. The async version of this credential
is available only on Python 3.7+.
([#19308](https://github.com/Azure/azure-sdk-for-python/issues/19308))

### Breaking Changes
> These changes do not impact the API of stable versions such as 1.6.0.
Expand Down
4 changes: 4 additions & 0 deletions sdk/identity/azure-identity/azure/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
EnvironmentCredential,
InteractiveBrowserCredential,
ManagedIdentityCredential,
OnBehalfOfCredential,
SharedTokenCacheCredential,
UsernamePasswordCredential,
VisualStudioCodeCredential,
)
from ._persistent_cache import TokenCachePersistenceOptions
from ._user_assertion import UserAssertion


__all__ = [
Expand All @@ -45,10 +47,12 @@
"EnvironmentCredential",
"InteractiveBrowserCredential",
"KnownAuthorities",
"OnBehalfOfCredential",
"RegionalAuthority",
"ManagedIdentityCredential",
"SharedTokenCacheCredential",
"TokenCachePersistenceOptions",
"UserAssertion",
"UsernamePasswordCredential",
"VisualStudioCodeCredential",
]
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,136 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import functools
import os
import time
from typing import TYPE_CHECKING

import msal

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

from .. import CredentialUnavailableError
from .._constants import EnvironmentVariables
from .._internal import get_default_authority, normalize_authority, resolve_tenant, validate_tenant_id
from .._internal.decorators import log_get_token
from .._internal.interactive import _build_auth_record
from .._internal.msal_client import MsalClient
from .._user_assertion import get_assertion

if TYPE_CHECKING:
from typing import Any


class OnBehalfOfCredential(object):
"""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.

Each token requested by this credential requires a user assertion from :class:`~azure.identity.UserAssertion`:

.. literalinclude:: ../tests/test_obo.py
:start-after: [START snippet]
:end-before: [END snippet]
:language: python
:dedent: 8

: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

: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**.
"""

def __init__(self, tenant_id, client_id, client_secret, **kwargs):
# type: (str, str, str, **Any) -> None
validate_tenant_id(tenant_id)
self._resolve_tenant = functools.partial(
resolve_tenant, tenant_id, kwargs.pop("allow_multitenant_authentication", False)
)

authority = kwargs.pop("authority", None)
Copy link
Member

Choose a reason for hiding this comment

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

Should authority be listed as a kwarg in the docstring?

self._authority = normalize_authority(authority) if authority else get_default_authority()
regional_authority = kwargs.pop(
"regional_authority", os.environ.get(EnvironmentVariables.AZURE_REGIONAL_AUTHORITY_NAME)
)

self._client = MsalClient(**kwargs)

# msal.ConfidentialClientApplication arguments which don't vary by user or tenant
self._confidential_client_args = {
"azure_region": regional_authority,
"client_capabilities": None if "AZURE_IDENTITY_DISABLE_CP1" in os.environ else ["CP1"],
"client_credential": client_secret,
"client_id": client_id,
"http_client": self._client,
}

@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')

user_assertion = get_assertion()
if not user_assertion:
raise CredentialUnavailableError(
(
"This credential requires a user assertion to acquire tokens. See "
+ "https://aka.ms/azsdk/python/identity/docs#azure.identity.OnBehalfOfCredential for more details."
)
)

# pylint:disable=protected-access
tenant_id = self._resolve_tenant(**kwargs)
if tenant_id not in user_assertion._client_applications:
user_assertion._client_applications[tenant_id] = msal.ConfidentialClientApplication(
authority=self._authority + "/" + tenant_id, **self._confidential_client_args
)
client_application = user_assertion._client_applications[tenant_id]

request_time = int(time.time())
result = None

if user_assertion._record:
# we already acquired a token with this assertion, so silent authentication may work
for account in client_application.get_accounts(username=user_assertion._record.username):
if account.get("home_account_id") != user_assertion._record.home_account_id:
continue
result = client_application.acquire_token_silent_with_error(
list(scopes), account=account, claims_challenge=kwargs.get("claims")
)
if result and "access_token" in result and "expires_in" in result:
break

if not result:
result = client_application.acquire_token_on_behalf_of(
user_assertion._assertion, list(scopes), claims_challenge=kwargs.get("claims")
)
try:
user_assertion._record = _build_auth_record(result)
except Exception: # pylint:disable=broad-except
pass

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 @@ -61,6 +61,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, config=None, policies=None, transport=None, **kwargs):
# type: (Optional[Configuration], Optional[List[Policy]], Optional[HttpTransport], **Any) -> Pipeline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,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, config=None, policies=None, transport=None, **kwargs):
pass
Expand Down Expand Up @@ -214,6 +218,19 @@ def _get_jwt_assertion(self, certificate, audience):

return jwt_bytes.decode("utf-8")

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
58 changes: 58 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_user_assertion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Dict, Optional
import msal
from . import AuthenticationRecord
from ._internal import AadClientBase


try:
from contextvars import ContextVar

_assertion_var = ContextVar("_user_assertion_context", default=None)

def get_assertion():
return _assertion_var.get()

def _set_assertion(user_assertion):
_assertion_var.set(user_assertion)


except ImportError:
from threading import local

_assertion_local = local()
_assertion_local.user_assertion = None

def get_assertion():
return _assertion_local.user_assertion

def _set_assertion(user_assertion):
_assertion_local.user_assertion = user_assertion


class UserAssertion(object):
def __init__(self, user_assertion):
# type: (str) -> None
"""A user assertion.

:param str user_assertion: the user assertion. Typically an access token issued to the user.
"""
self._assertion = user_assertion
self._async_clients = {} # type: Dict[str, AadClientBase]
self._client_applications = {} # type: Dict[str, msal.ConfidentialClientApplication]
self._record = None # type: Optional[AuthenticationRecord]

def __enter__(self):
if get_assertion():
raise ValueError("Another UserAssertion is already active for this context")
_set_assertion(self)
return self

def __exit__(self, *args):
_set_assertion(None)
7 changes: 7 additions & 0 deletions sdk/identity/azure-identity/azure/identity/aio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@
"SharedTokenCacheCredential",
"VisualStudioCodeCredential",
]

try:
from ._credentials import OnBehalfOfCredential # pylint:disable=unused-import

__all__.append("OnBehalfOfCredential")
except ImportError:
pass
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@
"SharedTokenCacheCredential",
"VisualStudioCodeCredential",
]


try:
from .on_behalf_of import OnBehalfOfCredential # pylint:disable=unused-import

__all__.append("OnBehalfOfCredential")
except ImportError:
pass
Loading