-
Notifications
You must be signed in to change notification settings - Fork 2.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add OnBehalfOfCredential #20451
Merged
Merged
Add OnBehalfOfCredential #20451
Changes from 11 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
36a0860
AadClient.obtain_token_on_behalf_of
chlowell e94c719
add OnBehalfOfCredential
chlowell 08cd5e1
delete OBO sample
chlowell da91919
tests
chlowell c46cb26
changelog
chlowell ad46ae4
fix tests
chlowell 336582f
these credentials should be context managers
chlowell 3efc1a5
use GetTokenMixin
chlowell cbb5d0c
redact PII in id tokens
chlowell c260426
support client certs
chlowell 02892ad
tests
chlowell 38d1b13
friendlier error message for invalid certs
chlowell fb71d6b
need msal only for type checking
chlowell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
# ------------------------------------ | ||
# Copyright (c) Microsoft Corporation. | ||
# Licensed under the MIT License. | ||
# ------------------------------------ | ||
import time | ||
from typing import cast, TYPE_CHECKING | ||
|
||
import msal | ||
import six | ||
|
||
from azure.core.credentials import AccessToken | ||
from azure.core.exceptions import ClientAuthenticationError | ||
|
||
from .certificate import get_client_credential | ||
from .._internal.decorators import wrap_exceptions | ||
from .._internal.get_token_mixin import GetTokenMixin | ||
from .._internal.interactive import _build_auth_record | ||
from .._internal.msal_credentials import MsalCredential | ||
|
||
if TYPE_CHECKING: | ||
from typing import Any, Dict, Optional, Union | ||
from .. import AuthenticationRecord | ||
|
||
|
||
class OnBehalfOfCredential(MsalCredential, GetTokenMixin): | ||
"""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 client_credential: a credential to authenticate the service principal, either one of its client secrets (a | ||
string) or the bytes of a certificate in PEM or PKCS12 format including the private key | ||
:type client_credential: str or bytes | ||
: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. | ||
:keyword password: a certificate password. Used only when **client_credential** is certificate bytes. If this value | ||
is a unicode string, it will be encoded as UTF-8. If the certificate requires a different encoding, pass | ||
appropriately encoded bytes instead. | ||
:paramtype password: str or bytes | ||
""" | ||
|
||
def __init__(self, tenant_id, client_id, client_credential, user_assertion, **kwargs): | ||
# type: (str, str, Union[bytes, str], str, **Any) -> None | ||
credential = cast("Union[Dict, str]", client_credential) | ||
if isinstance(client_credential, six.binary_type): | ||
try: | ||
credential = get_client_credential( | ||
certificate_path=None, password=kwargs.pop("password", None), certificate_data=client_credential | ||
) | ||
except ValueError: | ||
# client_credential isn't a cert, which is to be expected on 2.7 where str == bytes | ||
pass | ||
|
||
super(OnBehalfOfCredential, self).__init__(client_id, credential, tenant_id=tenant_id, **kwargs) | ||
self._assertion = user_assertion | ||
self._auth_record = None # type: Optional[AuthenticationRecord] | ||
|
||
@wrap_exceptions | ||
def _acquire_token_silently(self, *scopes, **kwargs): | ||
# type: (*str, **Any) -> Optional[AccessToken] | ||
if self._auth_record: | ||
claims = kwargs.get("claims") | ||
app = self._get_app(**kwargs) | ||
for account in app.get_accounts(username=self._auth_record.username): | ||
if account.get("home_account_id") != self._auth_record.home_account_id: | ||
continue | ||
|
||
now = int(time.time()) | ||
result = app.acquire_token_silent_with_error(list(scopes), account=account, claims_challenge=claims) | ||
if result and "access_token" in result and "expires_in" in result: | ||
return AccessToken(result["access_token"], now + int(result["expires_in"])) | ||
|
||
return None | ||
|
||
@wrap_exceptions | ||
def _request_token(self, *scopes, **kwargs): | ||
# type: (*str, **Any) -> AccessToken | ||
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 or "expires_in" 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) | ||
|
||
try: | ||
self._auth_record = _build_auth_record(result) | ||
except ClientAuthenticationError: | ||
pass # non-fatal; we'll use the assertion again next time instead of a refresh token | ||
|
||
return AccessToken(result["access_token"], request_time + int(result["expires_in"])) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
# ------------------------------------ | ||
# 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, AsyncContextManager | ||
from .._internal.get_token_mixin import GetTokenMixin | ||
from ..._credentials.certificate import get_client_credential | ||
from ..._internal import AadClientCertificate, validate_tenant_id | ||
|
||
if TYPE_CHECKING: | ||
from typing import Any, Optional, Union | ||
from azure.core.credentials import AccessToken | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
class OnBehalfOfCredential(AsyncContextManager, GetTokenMixin): | ||
"""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 client_credential: a credential to authenticate the service principal, either one of its client secrets (a | ||
string) or the bytes of a certificate in PEM or PKCS12 format including the private key | ||
:paramtype client_credential: str or bytes | ||
: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. | ||
:keyword password: a certificate password. Used only when **client_credential** is certificate bytes. If this value | ||
is a unicode string, it will be encoded as UTF-8. If the certificate requires a different encoding, pass | ||
appropriately encoded bytes instead. | ||
:paramtype password: str or bytes | ||
""" | ||
|
||
def __init__( | ||
self, | ||
tenant_id: str, | ||
client_id: str, | ||
client_credential: "Union[bytes, str]", | ||
user_assertion: str, | ||
**kwargs: "Any" | ||
) -> None: | ||
super().__init__() | ||
validate_tenant_id(tenant_id) | ||
|
||
if isinstance(client_credential, bytes): | ||
cert = get_client_credential(None, kwargs.pop("password", None), client_credential) | ||
self._credential = AadClientCertificate( | ||
cert["private_key"], password=cert.get("passphrase") | ||
) # type: Union[str, AadClientCertificate] | ||
else: | ||
self._credential = client_credential | ||
|
||
# note AadClient handles "allow_multitenant_authentication", "authority", and any pipeline kwargs | ||
self._client = AadClient(tenant_id, client_id, **kwargs) | ||
self._assertion = user_assertion | ||
|
||
async def __aenter__(self): | ||
await self._client.__aenter__() | ||
return self | ||
|
||
async def close(self): | ||
await self._client.close() | ||
|
||
async def _acquire_token_silently(self, *scopes: str, **kwargs: "Any") -> "Optional[AccessToken]": | ||
return self._client.get_cached_access_token(scopes, **kwargs) | ||
|
||
async def _request_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken": | ||
# 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"] | ||
return 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) | ||
|
||
# we don't have a refresh token, or silent auth failed: acquire a new token from the assertion | ||
return await self._client.obtain_token_on_behalf_of(scopes, self._credential, self._assertion, **kwargs) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.