Skip to content

Commit 1d28b02

Browse files
authored
User authentication API for applications (#10612)
1 parent 98bb6e9 commit 1d28b02

14 files changed

Lines changed: 807 additions & 101 deletions

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
# Release History
22

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

518
- Now `DefaultAzureCredential` can authenticate with the identity signed in to Visual
619
Studio Code's Azure extension. ([#10472](https://github.com/Azure/azure-sdk-for-python/issues/10472))

sdk/identity/azure-identity/azure/identity/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
# ------------------------------------
55
"""Credentials for Azure SDK clients."""
66

7-
from ._exceptions import CredentialUnavailableError
7+
from ._auth_record import AuthenticationRecord
8+
from ._exceptions import AuthenticationRequiredError, CredentialUnavailableError
89
from ._constants import KnownAuthorities
910
from ._credentials import (
1011
AuthorizationCodeCredential,
@@ -22,6 +23,8 @@
2223

2324

2425
__all__ = [
26+
"AuthenticationRecord",
27+
"AuthenticationRequiredError",
2528
"AuthorizationCodeCredential",
2629
"CertificateCredential",
2730
"ChainedTokenCredential",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
import json
6+
7+
8+
class AuthenticationRecord(object):
9+
"""A record which can initialize :class:`DeviceCodeCredential` or :class:`InteractiveBrowserCredential`"""
10+
11+
def __init__(self, tenant_id, client_id, authority, home_account_id, username):
12+
# type: (str, str, str, str, str) -> None
13+
self._authority = authority
14+
self._client_id = client_id
15+
self._home_account_id = home_account_id
16+
self._tenant_id = tenant_id
17+
self._username = username
18+
19+
@property
20+
def authority(self):
21+
# type: () -> str
22+
return self._authority
23+
24+
@property
25+
def client_id(self):
26+
# type: () -> str
27+
return self._client_id
28+
29+
@property
30+
def home_account_id(self):
31+
# type: () -> str
32+
return self._home_account_id
33+
34+
@property
35+
def tenant_id(self):
36+
# type: () -> str
37+
return self._tenant_id
38+
39+
@property
40+
def username(self):
41+
# type: () -> str
42+
"""The authenticated user's username"""
43+
return self._username
44+
45+
@classmethod
46+
def deserialize(cls, json_string):
47+
# type: (str) -> AuthenticationRecord
48+
"""Deserialize a record from JSON"""
49+
50+
deserialized = json.loads(json_string)
51+
52+
return cls(
53+
authority=deserialized["authority"],
54+
client_id=deserialized["client_id"],
55+
home_account_id=deserialized["home_account_id"],
56+
tenant_id=deserialized["tenant_id"],
57+
username=deserialized["username"],
58+
)
59+
60+
def serialize(self):
61+
# type: () -> str
62+
"""Serialize the record to JSON"""
63+
64+
record = {
65+
"authority": self._authority,
66+
"client_id": self._client_id,
67+
"home_account_id": self._home_account_id,
68+
"tenant_id": self._tenant_id,
69+
"username": self._username,
70+
}
71+
72+
return json.dumps(record)

sdk/identity/azure-identity/azure/identity/_credentials/browser.py

Lines changed: 10 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,14 @@
33
# Licensed under the MIT License.
44
# ------------------------------------
55
import socket
6-
import time
76
import uuid
87
import webbrowser
98

10-
from azure.core.credentials import AccessToken
119
from azure.core.exceptions import ClientAuthenticationError
1210

1311
from .. import CredentialUnavailableError
1412
from .._constants import AZURE_CLI_CLIENT_ID
15-
from .._internal import AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions
13+
from .._internal import AuthCodeRedirectServer, InteractiveCredential, wrap_exceptions
1614

1715
try:
1816
from typing import TYPE_CHECKING
@@ -24,7 +22,7 @@
2422
from typing import Any, List, Mapping
2523

2624

27-
class InteractiveBrowserCredential(PublicClientCredential):
25+
class InteractiveBrowserCredential(InteractiveCredential):
2826
"""Opens a browser to interactively authenticate a user.
2927
3028
:func:`~get_token` opens a browser to a login URL provided by Azure Active Directory and authenticates a user
@@ -38,6 +36,11 @@ class InteractiveBrowserCredential(PublicClientCredential):
3836
authenticate work or school accounts.
3937
:keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If
4038
unspecified, the Azure CLI's ID will be used.
39+
:keyword AuthenticationRecord authentication_record: :class:`AuthenticationRecord` returned by :func:`authenticate`
40+
:keyword bool disable_automatic_authentication: if True, :func:`get_token` will raise
41+
:class:`AuthenticationRequiredError` when user interaction is required to acquire a token. Defaults to False.
42+
:keyword bool enable_persistent_cache: if True, the credential will store tokens in a persistent cache shared by
43+
other user credentials. **This is only supported on Windows.** Defaults to False.
4144
:keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes).
4245
"""
4346

@@ -49,42 +52,9 @@ def __init__(self, **kwargs):
4952
super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs)
5053

5154
@wrap_exceptions
52-
def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
53-
# type: (*str, **Any) -> AccessToken
54-
"""Request an access token for `scopes`.
55-
56-
This will open a browser to a login page and listen on localhost for a request indicating authentication has
57-
completed.
58-
59-
.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.
60-
61-
:param str scopes: desired scopes for the access token. This method requires at least one scope.
62-
:rtype: :class:`azure.core.credentials.AccessToken`
63-
:raises ~azure.identity.CredentialUnavailableError: the credential is unable to start an HTTP server on
64-
localhost, or is unable to open a browser
65-
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
66-
attribute gives a reason. Any error response from Azure Active Directory is available as the error's
67-
``response`` attribute.
68-
"""
69-
if not scopes:
70-
raise ValueError("'get_token' requires at least one scope")
71-
72-
return self._get_token_from_cache(scopes, **kwargs) or self._get_token_by_auth_code(scopes, **kwargs)
55+
def _request_token(self, *scopes, **kwargs):
56+
# type: (*str, **Any) -> dict
7357

74-
def _get_token_from_cache(self, scopes, **kwargs):
75-
"""if the user has already signed in, we can redeem a refresh token for a new access token"""
76-
app = self._get_app()
77-
accounts = app.get_accounts()
78-
if accounts: # => user has already authenticated
79-
# MSAL asserts scopes is a list
80-
scopes = list(scopes) # type: ignore
81-
now = int(time.time())
82-
token = app.acquire_token_silent(scopes, account=accounts[0], **kwargs)
83-
if token and "access_token" in token and "expires_in" in token:
84-
return AccessToken(token["access_token"], now + int(token["expires_in"]))
85-
return None
86-
87-
def _get_token_by_auth_code(self, scopes, **kwargs):
8858
# start an HTTP server on localhost to receive the redirect
8959
for port in range(8400, 9000):
9060
try:
@@ -118,13 +88,8 @@ def _get_token_by_auth_code(self, scopes, **kwargs):
11888

11989
# redeem the authorization code for a token
12090
code = self._parse_response(request_state, response)
121-
now = int(time.time())
122-
result = app.acquire_token_by_authorization_code(code, scopes=scopes, redirect_uri=redirect_uri, **kwargs)
123-
124-
if "access_token" not in result:
125-
raise ClientAuthenticationError(message="Authentication failed: {}".format(result.get("error_description")))
91+
return app.acquire_token_by_authorization_code(code, scopes=scopes, redirect_uri=redirect_uri, **kwargs)
12692

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

12994
@staticmethod
13095
def _parse_response(request_state, response):

sdk/identity/azure-identity/azure/identity/_credentials/user.py

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from azure.core.credentials import AccessToken
99
from azure.core.exceptions import ClientAuthenticationError
1010

11-
from .._internal import PublicClientCredential, wrap_exceptions
11+
from .._internal import InteractiveCredential, PublicClientCredential, wrap_exceptions
1212

1313
try:
1414
from typing import TYPE_CHECKING
@@ -17,18 +17,16 @@
1717

1818
if TYPE_CHECKING:
1919
# pylint:disable=unused-import,ungrouped-imports
20-
from typing import Any, Callable, Optional
20+
from typing import Any, Optional
2121

2222

23-
class DeviceCodeCredential(PublicClientCredential):
23+
class DeviceCodeCredential(InteractiveCredential):
2424
"""Authenticates users through the device code flow.
2525
2626
When :func:`get_token` is called, this credential acquires a verification URL and code from Azure Active Directory.
2727
A user must browse to the URL, enter the code, and authenticate with Azure Active Directory. If the user
2828
authenticates successfully, the credential receives an access token.
2929
30-
This credential doesn't cache tokens--each :func:`get_token` call begins a new authentication flow.
31-
3230
For more information about the device code flow, see Azure Active Directory documentation:
3331
https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code
3432
@@ -49,6 +47,11 @@ class DeviceCodeCredential(PublicClientCredential):
4947
- ``expires_on`` (datetime.datetime) the UTC time at which the code will expire
5048
If this argument isn't provided, the credential will print instructions to stdout.
5149
:paramtype prompt_callback: Callable[str, str, ~datetime.datetime]
50+
:keyword AuthenticationRecord authentication_record: :class:`AuthenticationRecord` returned by :func:`authenticate`
51+
:keyword bool disable_automatic_authentication: if True, :func:`get_token` will raise
52+
:class:`AuthenticationRequiredError` when user interaction is required to acquire a token. Defaults to False.
53+
:keyword bool enable_persistent_cache: if True, the credential will store tokens in a persistent cache shared by
54+
other user credentials. **This is only supported on Windows.** Defaults to False.
5255
"""
5356

5457
def __init__(self, client_id, **kwargs):
@@ -58,26 +61,11 @@ def __init__(self, client_id, **kwargs):
5861
super(DeviceCodeCredential, self).__init__(client_id=client_id, **kwargs)
5962

6063
@wrap_exceptions
61-
def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
62-
# type: (*str, **Any) -> AccessToken
63-
"""Request an access token for `scopes`.
64-
65-
This credential won't cache the token. Each call begins a new authentication flow.
66-
67-
.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.
68-
69-
:param str scopes: desired scopes for the access token. This method requires at least one scope.
70-
:rtype: :class:`azure.core.credentials.AccessToken`
71-
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message``
72-
attribute gives a reason. Any error response from Azure Active Directory is available as the error's
73-
``response`` attribute.
74-
"""
75-
if not scopes:
76-
raise ValueError("'get_token' requires at least one scope")
64+
def _request_token(self, *scopes, **kwargs):
65+
# type: (*str, **Any) -> dict
7766

7867
# MSAL requires scopes be a list
7968
scopes = list(scopes) # type: ignore
80-
now = int(time.time())
8169

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

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

111-
token = AccessToken(result["access_token"], now + int(result["expires_in"]))
112-
return token
99+
return result
113100

114101

115102
class UsernamePasswordCredential(PublicClientCredential):

sdk/identity/azure-identity/azure/identity/_exceptions.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,37 @@
22
# Copyright (c) Microsoft Corporation.
33
# Licensed under the MIT License.
44
# ------------------------------------
5+
from typing import TYPE_CHECKING
6+
57
from azure.core.exceptions import ClientAuthenticationError
68

9+
if TYPE_CHECKING:
10+
from typing import Any, Optional, Sequence
11+
712

813
class CredentialUnavailableError(ClientAuthenticationError):
914
"""The credential did not attempt to authenticate because required data or state is unavailable."""
15+
16+
17+
class AuthenticationRequiredError(CredentialUnavailableError):
18+
"""Interactive authentication is required to acquire a token."""
19+
20+
def __init__(self, scopes, message=None, error_details=None, **kwargs):
21+
# type: (Sequence[str], Optional[str], Optional[str], **Any) -> None
22+
self._scopes = scopes
23+
self._error_details = error_details
24+
if not message:
25+
message = "Interactive authentication is required to get a token. Call 'authenticate' to begin."
26+
super(AuthenticationRequiredError, self).__init__(message=message, **kwargs)
27+
28+
@property
29+
def scopes(self):
30+
# type: () -> Sequence[str]
31+
"""Scopes requested during the failed authentication"""
32+
return self._scopes
33+
34+
@property
35+
def error_details(self):
36+
# type: () -> Optional[str]
37+
"""Additional authentication error details from Azure Active Directory"""
38+
return self._error_details

sdk/identity/azure-identity/azure/identity/_internal/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def get_default_authority():
3434
from .aad_client_base import AadClientBase
3535
from .auth_code_redirect_handler import AuthCodeRedirectServer
3636
from .exception_wrapper import wrap_exceptions
37-
from .msal_credentials import ConfidentialClientCredential, PublicClientCredential
37+
from .msal_credentials import ConfidentialClientCredential, InteractiveCredential, PublicClientCredential
3838
from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse
3939

4040

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

5353

5454
__all__ = [
55+
"_scopes_to_resource",
5556
"AadClient",
5657
"AadClientBase",
5758
"AuthCodeRedirectServer",
5859
"ConfidentialClientCredential",
60+
"get_default_authority",
61+
"InteractiveCredential",
5962
"MsalTransportAdapter",
6063
"MsalTransportResponse",
64+
"normalize_authority",
6165
"PublicClientCredential",
6266
"wrap_exceptions",
6367
]

0 commit comments

Comments
 (0)