Skip to content
13 changes: 13 additions & 0 deletions sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
# Release History

## 1.4.0b3 (Unreleased)
- First preview of new API for authenticating users with `DeviceCodeCredential`
and `InteractiveBrowserCredential`
- new method `authenticate` authenticates a user, returns a serializable
`AuthenticationRecord`
- new constructor keyword arguments
- `authentication_record` enables initializing a credential with an
`AuthenticationRecord` from a prior authentication
- `disable_automatic_authentication=True` configures the credential to raise
`AuthenticationRequiredError` when interactive authentication is necessary
to acquire a token rather than immediately begin that authentication
- `enable_persistent_cache=True` configures these credentials to use a
persistent cache on supported platforms (in this release, Windows only).
By default they cache in memory only.


## 1.4.0b2 (2020-04-06)
Expand Down
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_record import AuthenticationRecord
from ._exceptions import AuthenticationRequiredError, CredentialUnavailableError
from ._constants import KnownAuthorities
from ._credentials import (
AuthorizationCodeCredential,
Expand All @@ -22,6 +23,8 @@


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

if TYPE_CHECKING:
from typing import Any


class AuthenticationRecord(object):
"""A record which can initialize :class:`DeviceCodeCredential` or :class:`InteractiveBrowserCredential`"""

def __init__(self, tenant_id, client_id, authority, home_account_id, username, **kwargs):
# type: (str, str, str, str, str, **Any) -> None
self._additional_data = kwargs
self._authority = authority
self._client_id = client_id
self._home_account_id = home_account_id
self._tenant_id = tenant_id
self._username = username

@property
def authority(self):
# type: () -> str
return self._authority

@property
def client_id(self):
# type: () -> str
return self._client_id

@property
def home_account_id(self):
# type: () -> str
return self._home_account_id

@property
def tenant_id(self):
# type: () -> str
return self._tenant_id

@property
def username(self):
# type: () -> str
"""The authenticated user's username"""
return self._username

@property
def additional_data(self):
# type: () -> dict
"""Keyword arguments serialized with the record"""

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) -> AuthenticationRecord
"""Deserialize a record from JSON"""

deserialized = json.loads(json_string)

return cls(
authority=deserialized.pop("authority"),
client_id=deserialized.pop("client_id"),
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 record and any keyword arguments to JSON"""

record = dict(
{
"authority": self._authority,
"client_id": self._client_id,
"home_account_id": self._home_account_id,
"tenant_id": self._tenant_id,
"username": self._username,
},
**kwargs
)

return json.dumps(record)
55 changes: 10 additions & 45 deletions sdk/identity/azure-identity/azure/identity/_credentials/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@
# Licensed under the MIT License.
# ------------------------------------
import socket
import time
import uuid
import webbrowser

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

from .. import CredentialUnavailableError
from .._constants import AZURE_CLI_CLIENT_ID
from .._internal import AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions
from .._internal import AuthCodeRedirectServer, InteractiveCredential, wrap_exceptions

try:
from typing import TYPE_CHECKING
Expand All @@ -24,7 +22,7 @@
from typing import Any, List, Mapping


class InteractiveBrowserCredential(PublicClientCredential):
class InteractiveBrowserCredential(InteractiveCredential):
"""Opens a browser to interactively authenticate a user.

:func:`~get_token` opens a browser to a login URL provided by Azure Active Directory and authenticates a user
Expand All @@ -38,6 +36,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 AuthenticationRecord authentication_record: :class:`AuthenticationRecord` returned by :func:`authenticate`
:keyword bool disable_automatic_authentication: if True, :func:`get_token` will raise
:class:`AuthenticationRequiredError` when user interaction is required to acquire a token. Defaults to False.
:keyword bool enable_persistent_cache: if True, the credential will store tokens in a persistent cache shared by
other user credentials. **This is only supported on Windows.** Defaults to False.
:keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes).
"""

Expand All @@ -49,42 +52,9 @@ def __init__(self, **kwargs):
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`.

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

.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.

:param str scopes: desired scopes for the access token. This method requires at least one scope.
:rtype: :class:`azure.core.credentials.AccessToken`
: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.
"""
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)
def _request_token(self, *scopes, **kwargs):
# type: (*str, **Any) -> dict

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

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 +88,8 @@ 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 app.acquire_token_by_authorization_code(code, scopes=scopes, redirect_uri=redirect_uri, **kwargs)

return AccessToken(result["access_token"], now + int(result["expires_in"]))

@staticmethod
def _parse_response(request_state, response):
Expand Down
37 changes: 12 additions & 25 deletions sdk/identity/azure-identity/azure/identity/_credentials/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from azure.core.credentials import AccessToken
from azure.core.exceptions import ClientAuthenticationError

from .._internal import PublicClientCredential, wrap_exceptions
from .._internal import InteractiveCredential, PublicClientCredential, wrap_exceptions

try:
from typing import TYPE_CHECKING
Expand All @@ -17,18 +17,16 @@

if TYPE_CHECKING:
# pylint:disable=unused-import,ungrouped-imports
from typing import Any, Callable, Optional
from typing import Any, Optional


class DeviceCodeCredential(PublicClientCredential):
class DeviceCodeCredential(InteractiveCredential):
"""Authenticates users through the device code flow.

When :func:`get_token` is called, this credential acquires a verification URL and code from Azure Active Directory.
A user must browse to the URL, enter the code, and authenticate with Azure Active Directory. If the user
authenticates successfully, the credential receives an access token.

This credential doesn't cache tokens--each :func:`get_token` call begins a new authentication flow.

For more information about the device code flow, see Azure Active Directory documentation:
https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code

Expand All @@ -49,6 +47,11 @@ class DeviceCodeCredential(PublicClientCredential):
- ``expires_on`` (datetime.datetime) the UTC time at which the code will expire
If this argument isn't provided, the credential will print instructions to stdout.
:paramtype prompt_callback: Callable[str, str, ~datetime.datetime]
:keyword AuthenticationRecord authentication_record: :class:`AuthenticationRecord` returned by :func:`authenticate`
:keyword bool disable_automatic_authentication: if True, :func:`get_token` will raise
:class:`AuthenticationRequiredError` when user interaction is required to acquire a token. Defaults to False.
:keyword bool enable_persistent_cache: if True, the credential will store tokens in a persistent cache shared by
other user credentials. **This is only supported on Windows.** Defaults to False.
"""

def __init__(self, client_id, **kwargs):
Expand All @@ -58,26 +61,11 @@ def __init__(self, client_id, **kwargs):
super(DeviceCodeCredential, 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`.

This credential won't cache the token. Each call begins a new authentication flow.

.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.

:param str scopes: desired scopes for the access token. This method requires at least one scope.
:rtype: :class:`azure.core.credentials.AccessToken`
: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.
"""
if not scopes:
raise ValueError("'get_token' requires at least one scope")
def _request_token(self, *scopes, **kwargs):
# type: (*str, **Any) -> dict

# MSAL requires scopes be a list
scopes = list(scopes) # type: ignore
now = int(time.time())

app = self._get_app()
flow = app.initiate_device_flow(scopes)
Expand All @@ -95,7 +83,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument

if self._timeout is not None and self._timeout < flow["expires_in"]:
# user specified an effective timeout we will observe
deadline = now + self._timeout
deadline = int(time.time()) + self._timeout
result = app.acquire_token_by_device_flow(flow, exit_condition=lambda flow: time.time() > deadline)
else:
# MSAL will stop polling when the device code expires
Expand All @@ -108,8 +96,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
message = "Authentication failed: {}".format(result.get("error_description") or result.get("error"))
raise ClientAuthenticationError(message=message)

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


class UsernamePasswordCredential(PublicClientCredential):
Expand Down
29 changes: 29 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,37 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from typing import TYPE_CHECKING

from azure.core.exceptions import ClientAuthenticationError

if TYPE_CHECKING:
from typing import Any, Optional, Sequence


class CredentialUnavailableError(ClientAuthenticationError):
"""The credential did not attempt to authenticate because required data or state is unavailable."""


class AuthenticationRequiredError(CredentialUnavailableError):
Copy link
Member

Choose a reason for hiding this comment

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

Why AuthenticationRequiredError is a subclass of CredentialUnavailableError?

It seems to me AuthenticationRequiredError is a HttpRequestError but CredentialUnavailableError may be not?

Copy link
Member Author

Choose a reason for hiding this comment

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

Silent authentication can succeed or fail without sending a request. The question is really about the behavior of chained credentials: if a credential is configured to authenticate silently only, should a chain try its next credential when silent auth fails? I lean toward "yes", hence CredentialUnavailableError. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

It can't go under HttpRequestError, because as Charles said there might have been no request made when we determine that authentication is required. As far as if it should fall under CredentialUnavailableError, i.e. allow other credentials in a chain to be attempted, I also lean towards yes. However, as we develop this more we might find we have to tweak this over the course of the preview.

"""Interactive authentication is required to acquire a token."""

def __init__(self, scopes, message=None, error_details=None, **kwargs):
# type: (Sequence[str], Optional[str], Optional[str], **Any) -> None
self._scopes = scopes
self._error_details = error_details
if not message:
message = "Interactive authentication is required to get a token. Call 'authenticate' to begin."
super(AuthenticationRequiredError, self).__init__(message=message, **kwargs)

@property
def scopes(self):
# type: () -> Sequence[str]
"""Scopes requested during the failed authentication"""
return self._scopes

@property
def error_details(self):
# type: () -> Optional[str]
"""Additional authentication error details from Azure Active Directory"""
return self._error_details
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def get_default_authority():
from .aad_client_base import AadClientBase
from .auth_code_redirect_handler import AuthCodeRedirectServer
from .exception_wrapper import wrap_exceptions
from .msal_credentials import ConfidentialClientCredential, PublicClientCredential
from .msal_credentials import ConfidentialClientCredential, InteractiveCredential, PublicClientCredential
from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse


Expand All @@ -52,12 +52,16 @@ def _scopes_to_resource(*scopes):


__all__ = [
"_scopes_to_resource",
"AadClient",
"AadClientBase",
"AuthCodeRedirectServer",
"ConfidentialClientCredential",
"get_default_authority",
"InteractiveCredential",
"MsalTransportAdapter",
"MsalTransportResponse",
"normalize_authority",
"PublicClientCredential",
"wrap_exceptions",
]
Loading