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
5 changes: 4 additions & 1 deletion sdk/identity/azure-identity/azure/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# ------------------------------------
"""Credentials for Azure SDK clients."""

from ._exceptions import CredentialUnavailableError
from ._auth_profile import AuthProfile
from ._exceptions import AuthenticationRequiredError, CredentialUnavailableError
from ._constants import KnownAuthorities
from ._credentials import (
AuthorizationCodeCredential,
Expand All @@ -22,6 +23,8 @@


__all__ = [
"AuthenticationRequiredError",
"AuthProfile",
"AuthorizationCodeCredential",
"CertificateCredential",
"ChainedTokenCredential",
Expand Down
68 changes: 68 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_auth_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import json
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Any


class AuthProfile(object):
"""Public user information from an authentication.

:param str environment: the Azure Active Directory instance which authenticated the user
:param str home_account_id: the user's Azure Active Directory object ID and home tenant ID
:param str tenant_id: the tenant which authenticated the user
:param str username: the user's username (usually an email address)
"""

def __init__(self, environment, home_account_id, tenant_id, username, **kwargs):
# type: (str, str, str, str, **Any) -> None
self._additional_data = kwargs
self.environment = environment
self.home_account_id = home_account_id
self.tenant_id = tenant_id
self.username = username

@property
def additional_data(self):
# type: () -> dict
"""A dictionary of extra data deserialized alongside the profile"""

return dict(self._additional_data)

def __getitem__(self, key):
return getattr(self, key, None) or self._additional_data[key]

@classmethod
def deserialize(cls, json_string):
# type: (str) -> AuthProfile
"""Deserialize a profile from JSON"""

deserialized = json.loads(json_string)

return cls(
environment=deserialized.pop("environment"),
home_account_id=deserialized.pop("home_account_id"),
tenant_id=deserialized.pop("tenant_id"),
username=deserialized.pop("username"),
**deserialized
)

def serialize(self, **kwargs):
# type: (**Any) -> str
"""Serialize the profile and any keyword arguments to JSON"""

profile = dict(
{
"environment": self.environment,
"home_account_id": self.home_account_id,
"tenant_id": self.tenant_id,
"username": self.username,
},
**kwargs
)

return json.dumps(profile)
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
from azure.core.credentials import AccessToken
from azure.core.exceptions import ClientAuthenticationError

from .. import CredentialUnavailableError
from .. import AuthenticationRequiredError, CredentialUnavailableError
from .._constants import AZURE_CLI_CLIENT_ID
from .._internal import AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions
from .._internal import ARM_SCOPE, AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions
from .._internal.msal_credentials import _build_auth_profile

try:
from typing import TYPE_CHECKING
Expand All @@ -21,7 +22,8 @@

if TYPE_CHECKING:
# pylint:disable=unused-import
from typing import Any, List, Mapping
from typing import Any, List, Mapping, Tuple
from .. import AuthProfile


class InteractiveBrowserCredential(PublicClientCredential):
Expand All @@ -38,6 +40,11 @@ class InteractiveBrowserCredential(PublicClientCredential):
authenticate work or school accounts.
:keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If
unspecified, the Azure CLI's ID will be used.
:keyword ~azure.identity.AuthProfile profile: a user profile from a prior authentication. If provided, keyword
arguments ``authority`` and ``tenant_id`` will be ignored because the profile contains this information.
:keyword bool silent_auth_only: authenticate only silently (without user interaction). False by default. If True,
:func:`~get_token` will raise :class:`~azure.identity.AuthenticationRequiredError` when it cannot
authenticate silently.
:keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes).
"""

Expand All @@ -48,7 +55,6 @@ def __init__(self, **kwargs):
client_id = kwargs.pop("client_id", AZURE_CLI_CLIENT_ID)
super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs)

@wrap_exceptions
def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
# type: (*str, **Any) -> AccessToken
"""Request an access token for `scopes`.
Expand All @@ -65,26 +71,57 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
attribute gives a reason. Any error response from Azure Active Directory is available as the error's
``response`` attribute.
:raises ~azure.identity.AuthenticationRequiredError: the credential is configured to authenticate only silently
(without user interaction), and was unable to do so.
"""
if not scopes:
raise ValueError("'get_token' requires at least one scope")

return self._get_token_from_cache(scopes, **kwargs) or self._get_token_by_auth_code(scopes, **kwargs)
token = self._acquire_token_silent(*scopes, **kwargs)
if not token:
if self._silent_auth_only:
raise AuthenticationRequiredError()

Choose a reason for hiding this comment

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

please add the MSAL error result in the error exception so that client side can get the fail reason when get the access token

def _get_token_from_cache(self, scopes, **kwargs):
"""if the user has already signed in, we can redeem a refresh token for a new access token"""
app = self._get_app()
accounts = app.get_accounts()
if accounts: # => user has already authenticated
# MSAL asserts scopes is a list
scopes = list(scopes) # type: ignore
now = int(time.time())
token = app.acquire_token_silent(scopes, account=accounts[0], **kwargs)
if token and "access_token" in token and "expires_in" in token:
return AccessToken(token["access_token"], now + int(token["expires_in"]))
return None
response = self._get_token_by_auth_code(*scopes, **kwargs)

# update profile because the user may have authenticated a different identity
self._profile = _build_auth_profile(response)

token = AccessToken(response["access_token"], now + int(response["expires_in"]))

return token

@classmethod
def authenticate(cls, client_id, **kwargs):
Copy link

Choose a reason for hiding this comment

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

Maybe we can let client_id default to None? Thus we can automatically use AZURE_CLI_CLIENT_ID. Otherwise,

            credential, profile = InteractiveBrowserCredential.authenticate(
                silent_auth_only=True,
                scope='https://management.azure.com/.default'
            )

gives

TypeError: authenticate() missing 1 required positional argument: 'client_id'

Copy link
Owner Author

Choose a reason for hiding this comment

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

This API is intended for applications beyond the CLI, which we don't want unintentionally authenticating users to the CLI. It's true InteractiveBrowserCredential.get_token does that today, but that was designed for a different scenario and in a future release its implicit default client id will change.

Copy link

Choose a reason for hiding this comment

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

I see. Thanks for the clarification.

# type: (str, **Any) -> Tuple[InteractiveBrowserCredential, AuthProfile]
"""Authenticate a user. Returns a credential ready to get tokens for that user, and a user profile.

This method will open a browser to a login page and listen on localhost for a request indicating authentication
has completed.

Accepts the same keyword arguments as :class:`~InteractiveBrowserCredential`

:param str client_id: Client ID of the Azure Active Directory application the user will sign in to
:rtype: ~azure.identity.InteractiveBrowserCredential, ~azure.identity.AuthProfile
:raises ~azure.identity.CredentialUnavailableError: the credential is unable to start an HTTP server on
localhost, or is unable to open a browser
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
attribute gives a reason. Any error response from Azure Active Directory is available as the error's
``response`` attribute.
"""
# pylint:disable=protected-access
scope = kwargs.pop("scope", None) or ARM_SCOPE

credential = cls(client_id=client_id, **kwargs)
response = credential._get_token_by_auth_code(scope)
profile = _build_auth_profile(response)
credential._profile = profile

def _get_token_by_auth_code(self, scopes, **kwargs):
return credential, profile

@wrap_exceptions
def _get_token_by_auth_code(self, *scopes, **kwargs):
# start an HTTP server on localhost to receive the redirect
for port in range(8400, 9000):
try:
Expand Down Expand Up @@ -118,13 +155,12 @@ def _get_token_by_auth_code(self, scopes, **kwargs):

# redeem the authorization code for a token
code = self._parse_response(request_state, response)
now = int(time.time())
result = app.acquire_token_by_authorization_code(code, scopes=scopes, redirect_uri=redirect_uri, **kwargs)

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"]))
return result

@staticmethod
def _parse_response(request_state, response):
Expand Down
Loading