Skip to content
Merged
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
6 changes: 6 additions & 0 deletions sdk/identity/azure-identity/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Release History

## 1.0.0b4
### New features:
- `AuthorizationCodeCredential` authenticates with a previously obtained
authorization code. See Azure Active Directory's
[authorization code documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow)
for more information about this authentication flow.

### Fixes and improvements:
- `UsernamePasswordCredential` correctly handles environment configuration with
no tenant information (#7260)
Expand Down
8 changes: 7 additions & 1 deletion sdk/identity/azure-identity/azure/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,34 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from ._constants import EnvironmentVariables, KnownAuthorities
from ._credentials import (
InteractiveBrowserCredential,
AuthorizationCodeCredential,

CertificateCredential,
ChainedTokenCredential,
ClientSecretCredential,
DefaultAzureCredential,
DeviceCodeCredential,
EnvironmentCredential,
InteractiveBrowserCredential,
ManagedIdentityCredential,
SharedTokenCacheCredential,
UsernamePasswordCredential,
)


__all__ = [
"AuthorizationCodeCredential",
"CertificateCredential",
"ChainedTokenCredential",
"ClientSecretCredential",
"DefaultAzureCredential",
"DeviceCodeCredential",
"EnvironmentCredential",
"EnvironmentVariables",
"InteractiveBrowserCredential",
"KnownAuthorities",
"ManagedIdentityCredential",
"SharedTokenCacheCredential",
"UsernamePasswordCredential",
Expand Down
10 changes: 8 additions & 2 deletions sdk/identity/azure-identity/azure/identity/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
AZURE_CLI_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"


class KnownAuthorities:
AZURE_CHINA = "login.chinacloudapi.cn"
AZURE_GERMANY = "login.microsoftonline.de"
AZURE_GOVERNMENT = "login.microsoftonline.us"
AZURE_PUBLIC_CLOUD = "login.microsoftonline.com"


class EnvironmentVariables:
AZURE_CLIENT_ID = "AZURE_CLIENT_ID"
AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET"
Expand All @@ -28,5 +35,4 @@ class Endpoints:
# https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http
IMDS = "http://169.254.169.254/metadata/identity/oauth2/token"

# TODO: other clouds have other endpoints
AAD_OAUTH2_V2_FORMAT = "https://login.microsoftonline.com/{}/oauth2/v2.0/token"
AAD_OAUTH2_V2_FORMAT = "https://" + KnownAuthorities.AZURE_PUBLIC_CLOUD + "/{}/oauth2/v2.0/token"
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from .authorization_code import AuthorizationCodeCredential
from .browser import InteractiveBrowserCredential
from .chained import ChainedTokenCredential
from .client_credential import CertificateCredential, ClientSecretCredential
Expand All @@ -12,6 +13,7 @@


__all__ = [
"AuthorizationCodeCredential",
"CertificateCredential",
"ChainedTokenCredential",
"ClientSecretCredential",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from typing import TYPE_CHECKING

from azure.core.exceptions import ClientAuthenticationError
from .._internal.aad_client import AadClient

if TYPE_CHECKING:
# pylint:disable=unused-import,ungrouped-imports
from typing import Any, Iterable, Optional
from azure.core.credentials import AccessToken


class AuthorizationCodeCredential(object):
"""
Authenticates by redeeming an authorization code previously obtained from Azure Active Directory.
See https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow for more information
about the authentication flow.

:param str client_id: the application's client ID
:param str tenant_id: ID of the application's Azure Active Directory tenant. Also called its 'directory' ID.
:param str authorization_code: the authorization code from the user's log-in
:param str redirect_uri: The application's redirect URI. Must match the URI used to request the authorization code.
:param str client_secret: One of the application's client secrets. Required only for web apps and web APIs.

Keyword arguments
- **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.KnownAuthorities` defines
authorities for other clouds.
"""

def __init__(self, client_id, tenant_id, authorization_code, redirect_uri, client_secret=None, **kwargs):
# type: (str, str, str, str, Optional[str], **Any) -> None
self._authorization_code = authorization_code # type: Optional[str]
self._client_id = client_id
self._client_secret = client_secret
self._client = kwargs.pop("client", None) or AadClient(client_id, tenant_id, **kwargs)
self._redirect_uri = redirect_uri

def get_token(self, *scopes, **kwargs):
# type: (*str, **Any) -> AccessToken
"""
Request an access token for ``scopes``. The first time this method is called, the credential will redeem its
authorization code. On subsequent calls the credential will return a cached access token or redeem a refresh
token, if it acquired a refresh token upon redeeming the authorization code.

:param str scopes: desired scopes for the access token
:rtype: :class:`azure.core.credentials.AccessToken`
:raises: :class:`azure.core.exceptions.ClientAuthenticationError`
"""

if self._authorization_code:
token = self._client.obtain_token_by_authorization_code(
code=self._authorization_code, redirect_uri=self._redirect_uri, scopes=scopes, **kwargs
)
self._authorization_code = None # auth codes are single-use
return token

token = self._client.get_cached_access_token(scopes) or self._redeem_refresh_token(scopes, **kwargs)
if not token:
raise ClientAuthenticationError(
message="No authorization code, cached access token, or refresh token available."
)

return token

def _redeem_refresh_token(self, scopes, **kwargs):
# type: (Iterable[str], **Any) -> Optional[AccessToken]
for refresh_token in self._client.get_cached_refresh_tokens(scopes):
token = self._client.obtain_token_by_refresh_token(refresh_token, scopes, **kwargs)
if token:
return token
return None
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from .aad_client import AadClient
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_transport_adapter import MsalTransportAdapter, MsalTransportResponse

__all__ = [
"AadClient",
"AadClientBase",
"AuthCodeRedirectServer",
"ConfidentialClientCredential",
"MsalTransportAdapter",
Expand Down
30 changes: 30 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_internal/aad_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
"""A thin wrapper around MSAL's token cache and OAuth 2 client"""

import time
from typing import TYPE_CHECKING

from azure.core.credentials import AccessToken

from .aad_client_base import AadClientBase
from .msal_transport_adapter import MsalTransportAdapter
from .exception_wrapper import wrap_exceptions

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


class AadClient(AadClientBase):
def _get_client_session(self, **kwargs):
return MsalTransportAdapter(**kwargs)

@wrap_exceptions
def _obtain_token(self, scopes, fn, **kwargs): # pylint:disable=unused-argument
# type: (Iterable[str], Callable, **Any) -> AccessToken
now = int(time.time())
response = fn()
return self._process_response(response=response, scopes=scopes, now=now)
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import abc
import functools
import time

try:
from typing import TYPE_CHECKING
except ImportError:
TYPE_CHECKING = False

from msal import TokenCache
from msal.oauth2cli.oauth2 import Client

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

try:
ABC = abc.ABC
except AttributeError: # Python 2.7, abc exists, but not ABC
ABC = abc.ABCMeta("ABC", (object,), {"__slots__": ()}) # type: ignore

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


class AadClientBase(ABC):
"""Sans I/O methods for AAD clients wrapping MSAL's OAuth client"""

def __init__(self, client_id, tenant, **kwargs):
# type: (str, str, **Any) -> None
authority = kwargs.pop("authority", KnownAuthorities.AZURE_PUBLIC_CLOUD)
if authority[-1] == "/":
authority = authority[:-1]
token_endpoint = "https://" + "/".join((authority, tenant, "oauth2/v2.0/token"))
config = {"token_endpoint": token_endpoint}

self._client = Client(server_configuration=config, client_id=client_id)
self._client.session.close()
self._client.session = self._get_client_session(**kwargs)
self._cache = TokenCache()

def get_cached_access_token(self, scopes):
# type: (Iterable[str]) -> Optional[AccessToken]
tokens = self._cache.find(TokenCache.CredentialType.ACCESS_TOKEN, target=list(scopes))
for token in tokens:
expires_on = int(token["expires_on"])
if expires_on - 300 > int(time.time()):
return AccessToken(token["secret"], expires_on)
return None

def get_cached_refresh_tokens(self, scopes):
"""Assumes all cached refresh tokens belong to the same user"""
return self._cache.find(TokenCache.CredentialType.REFRESH_TOKEN, target=list(scopes))

def obtain_token_by_authorization_code(self, code, redirect_uri, scopes, **kwargs):
# type: (str, str, Iterable[str], **Any) -> AccessToken
fn = functools.partial(
self._client.obtain_token_by_authorization_code, code=code, redirect_uri=redirect_uri, **kwargs
)
return self._obtain_token(scopes, fn, **kwargs)

def obtain_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
# type: (str, Iterable[str], **Any) -> AccessToken
fn = functools.partial(
self._client.obtain_token_by_refresh_token,
token_item=refresh_token,
scope=scopes,
rt_getter=lambda token: token["secret"],
**kwargs
)
return self._obtain_token(scopes, fn, **kwargs)

def _process_response(self, response, scopes, now):
# type: (dict, Iterable[str], int) -> AccessToken
_raise_for_error(response)
self._cache.add(event={"response": response, "scope": scopes}, now=now)
if "expires_on" in response:
expires_on = int(response["expires_on"])
elif "expires_in" in response:
expires_on = now + int(response["expires_in"])
else:
_scrub_secrets(response)
raise ClientAuthenticationError(
message="Unexpected response from Azure Active Directory: {}".format(response)
)
return AccessToken(response["access_token"], expires_on)

@abc.abstractmethod
def _get_client_session(self, **kwargs):
pass

@abc.abstractmethod
def _obtain_token(self, scopes, fn, **kwargs):
# type: (Iterable[str], Callable, **Any) -> AccessToken
pass


def _scrub_secrets(response):
for secret in ("access_token", "refresh_token"):
if secret in response:
response[secret] = "***"


def _raise_for_error(response):
# type: (dict) -> None
if "error" not in response:
return

_scrub_secrets(response)
if "error_description" in response:
message = "Azure Active Directory error '({}) {}'".format(response["error"], response["error_description"])
else:
message = "Azure Active Directory error '{}'".format(response)
raise ClientAuthenticationError(message=message)
2 changes: 2 additions & 0 deletions sdk/identity/azure-identity/azure/identity/aio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Licensed under the MIT License.
# ------------------------------------
from ._credentials import (
AuthorizationCodeCredential,
CertificateCredential,
ChainedTokenCredential,
ClientSecretCredential,
Expand All @@ -14,6 +15,7 @@


__all__ = [
"AuthorizationCodeCredential",
"CertificateCredential",
"ClientSecretCredential",
"DefaultAzureCredential",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from .authorization_code import AuthorizationCodeCredential
from .chained import ChainedTokenCredential
from .default import DefaultAzureCredential
from .environment import EnvironmentCredential
Expand All @@ -11,6 +12,7 @@


__all__ = [
"AuthorizationCodeCredential",
"CertificateCredential",
"ChainedTokenCredential",
"ClientSecretCredential",
Expand Down
Loading