Skip to content
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

Enable SSO on Windows #7006

Merged
merged 14 commits into from
Sep 9, 2019
15 changes: 15 additions & 0 deletions sdk/identity/azure-identity/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Release History

## 1.0.0b3 (2019-09-10)
### New features:
- `SharedTokenCacheCredential` authenticates with tokens stored in a local
cache shared by Microsoft applications. This enables Azure SDK clients to
authenticate silently after you've signed in to Visual Studio 2019, for
example. `DefaultAzureCredential` includes `SharedTokenCacheCredential` when
the shared cache is available, and environment variable `AZURE_USERNAME`
is set. See the
[README](https://github.com/Azure/azure-sdk-for-python/blob/tree/master/sdk/identity/azure-identity/README.md#single-sign-on)
for more information.

### Dependency changes:
- New dependency: [`msal-extensions`](https://pypi.org/project/msal-extensions/)
0.1.1

## 1.0.0b2 (2019-08-05)
### Breaking changes:
- Removed `azure.core.Configuration` from the public API in preparation for a
Expand Down
9 changes: 8 additions & 1 deletion sdk/identity/azure-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ configuration:

|credential class|identity|configuration
|-|-|-
|`DefaultAzureCredential`|service principal, managed identity or user|none for managed identity; [environment variables](#environment-variables) for service principal or user authentication
|`DefaultAzureCredential`|service principal, managed identity, user|none for managed identity, [environment variables](#environment-variables) for service principal or user authentication
|`ManagedIdentityCredential`|managed identity|none
|`EnvironmentCredential`|service principal|[environment variables](#environment-variables)
|`ClientSecretCredential`|service principal|constructor parameters
Expand Down Expand Up @@ -93,6 +93,13 @@ require platform support. See the
[managed identity documentation](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/services-support-managed-identities)
for more information.

### Single sign-on
During local development on Windows, `DefaultAzureCredential` can authenticate
using a single sign-on shared with Microsoft applications, for example Visual
Studio 2019. Because you may have multiple signed in identities, to
authenticate this way you must set the environment variable `AZURE_USERNAME`
with your desired identity's username (typically an email address).

## Environment variables

`DefaultAzureCredential` and `EnvironmentCredential` can be configured with
Expand Down
36 changes: 29 additions & 7 deletions sdk/identity/azure-identity/azure/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import os

from ._browser_auth import InteractiveBrowserCredential
from ._constants import EnvironmentVariables
from .credentials import (
CertificateCredential,
ChainedTokenCredential,
ClientSecretCredential,
DeviceCodeCredential,
EnvironmentCredential,
ManagedIdentityCredential,
SharedTokenCacheCredential,
UsernamePasswordCredential,
)

Expand All @@ -18,17 +22,34 @@ class DefaultAzureCredential(ChainedTokenCredential):
"""
A default credential capable of handling most Azure SDK authentication scenarios.

When environment variable configuration is present, it authenticates as a service principal
using :class:`azure.identity.EnvironmentCredential`.
The identity it uses depends on the environment. When an access token is needed, it requests one using these
identities in turn, stopping when one provides a token:

When environment configuration is not present, it authenticates with a managed identity
using :class:`azure.identity.ManagedIdentityCredential`.
1. A service principal configured by environment variables. See :class:`~azure.identity.EnvironmentCredential` for
more details.
2. An Azure managed identity. See :class:`~azure.identity.ManagedIdentityCredential` for more details.
3. On Windows only: a user who has signed in with a Microsoft application, such as Visual Studio. This requires a
value for the environment variable ``AZURE_USERNAME``. See :class:`~azure.identity.SharedTokenCacheCredential`
for more details.
"""

def __init__(self, **kwargs):
super(DefaultAzureCredential, self).__init__(
EnvironmentCredential(**kwargs), ManagedIdentityCredential(**kwargs)
)
credentials = [EnvironmentCredential(**kwargs), ManagedIdentityCredential(**kwargs)]

# SharedTokenCacheCredential is part of the default only on supported platforms, when $AZURE_USERNAME has a
# value (because the cache may contain tokens for multiple identities and we can only choose one arbitrarily
# without more information from the user), and when $AZURE_PASSWORD has no value (because when $AZURE_USERNAME
# and $AZURE_PASSWORD are set, EnvironmentCredential will be used instead)
if (
SharedTokenCacheCredential.supported()
and EnvironmentVariables.AZURE_USERNAME in os.environ
and EnvironmentVariables.AZURE_PASSWORD not in os.environ
):
credentials.append(
SharedTokenCacheCredential(username=os.environ.get(EnvironmentVariables.AZURE_USERNAME), **kwargs)
)

super(DefaultAzureCredential, self).__init__(*credentials)


__all__ = [
Expand All @@ -40,5 +61,6 @@ def __init__(self, **kwargs):
"EnvironmentCredential",
"InteractiveBrowserCredential",
"ManagedIdentityCredential",
"SharedTokenCacheCredential",
"UsernamePasswordCredential",
]
77 changes: 70 additions & 7 deletions sdk/identity/azure-identity/azure/identity/_authn_client.py
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.
# ------------------------------------
import abc
import calendar
import time

Expand All @@ -14,6 +15,12 @@
from azure.core.pipeline.policies import ContentDecodePolicy, NetworkTraceLoggingPolicy, ProxyPolicy, RetryPolicy
from azure.core.pipeline.policies.distributed_tracing import DistributedTracingPolicy
from azure.core.pipeline.transport import RequestsTransport
from azure.identity._constants import AZURE_CLI_CLIENT_ID
Copy link
Member

Choose a reason for hiding this comment

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

FYI I made clientId a required parameter in the ctor for .NET and just use this when I construct it in the DefaultAzureCredential


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

try:
from typing import TYPE_CHECKING
Expand All @@ -29,7 +36,7 @@
from azure.core.pipeline.policies import HTTPPolicy


class AuthnClientBase(object):
class AuthnClientBase(ABC):
"""Sans I/O authentication client methods"""

def __init__(self, auth_url, **kwargs): # pylint:disable=unused-argument
Expand All @@ -38,20 +45,48 @@ def __init__(self, auth_url, **kwargs): # pylint:disable=unused-argument
raise ValueError("auth_url should be the URL of an OAuth endpoint")
super(AuthnClientBase, self).__init__()
self._auth_url = auth_url
self._cache = TokenCache()
self._cache = kwargs.get("cache") or TokenCache() # type: TokenCache

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

def get_refresh_tokens(self, scopes, account):
"""Yields all an account's cached refresh tokens except those which have a scope (which is unexpected) that
isn't a superset of ``scopes``."""

for token in self._cache.find(
TokenCache.CredentialType.REFRESH_TOKEN, query={"home_account_id": account.get("home_account_id")}
):
if "target" in token and not all((scope in token["target"] for scope in scopes)):
continue
yield token

def get_refresh_token_grant_request(self, refresh_token, scopes):
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token["secret"],
"scope": " ".join(scopes),
"client_id": AZURE_CLI_CLIENT_ID, # TODO: first-party app for SDK?
}
return self._prepare_request(form_data=data)

@abc.abstractmethod
def request_token(self, scopes, method, headers, form_data, params, **kwargs):
pass

@abc.abstractmethod
def obtain_token_by_refresh_token(self, scopes, username):
pass

def _deserialize_and_cache_token(self, response, scopes, request_time):
# type: (PipelineResponse, Iterable[str], int) -> AccessToken
"""Deserialize and cache an access token from an AAD response"""

# ContentDecodePolicy sets this, and should have raised if it couldn't deserialize the response
payload = response.context[ContentDecodePolicy.CONTEXT_NAME]
Expand Down Expand Up @@ -165,6 +200,34 @@ def request_token(
token = self._deserialize_and_cache_token(response=response, scopes=scopes, request_time=request_time)
return token

def obtain_token_by_refresh_token(self, scopes, username):
# type: (Iterable[str], str) -> Optional[AccessToken]
"""Acquire an access token using a cached refresh token. Returns ``None`` when that fails, or the cache has no
refresh token. This is only used by SharedTokenCacheCredential and isn't robust enough for anything else."""

# find account matching username
accounts = self._cache.find(TokenCache.CredentialType.ACCOUNT, query={"username": username})
for account in accounts:
# try each refresh token that might work, return the first access token acquired
for token in self.get_refresh_tokens(scopes, account):
# currently we only support login.microsoftonline.com, which has an alias login.windows.net
# TODO: this must change to support sovereign clouds
environment = account.get("environment")
if not environment or (environment not in self._auth_url and environment != "login.windows.net"):
continue

request = self.get_refresh_token_grant_request(token, scopes)
request_time = int(time.time())
response = self._pipeline.run(request, stream=False)
try:
return self._deserialize_and_cache_token(
response=response, scopes=scopes, request_time=request_time
)
except ClientAuthenticationError:
continue

return None

@staticmethod
def _create_config(**kwargs):
# type: (Mapping[str, Any]) -> Configuration
Expand Down
3 changes: 3 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
# ------------------------------------


AZURE_CLI_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"


class EnvironmentVariables:
AZURE_CLIENT_ID = "AZURE_CLIENT_ID"
AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET"
Expand Down
2 changes: 1 addition & 1 deletion sdk/identity/azure-identity/azure/identity/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
VERSION = "1.0.0b2"
VERSION = "1.0.0b3"
34 changes: 29 additions & 5 deletions sdk/identity/azure-identity/azure/identity/aio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,51 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import os

from .._constants import EnvironmentVariables
from .credentials import (
CertificateCredential,
ChainedTokenCredential,
ClientSecretCredential,
EnvironmentCredential,
ManagedIdentityCredential,
SharedTokenCacheCredential,
)


class DefaultAzureCredential(ChainedTokenCredential):
"""
A default credential capable of handling most Azure SDK authentication scenarios.

When environment variable configuration is present, it authenticates as a service principal
using :class:`azure.identity.aio.EnvironmentCredential`.
The identity it uses depends on the environment. When an access token is needed, it requests one using these
identities in turn, stopping when one provides a token:

When environment configuration is not present, it authenticates with a managed identity
using :class:`azure.identity.aio.ManagedIdentityCredential`.
1. A service principal configured by environment variables. See :class:`~azure.identity.aio.EnvironmentCredential`
for more details.
2. An Azure managed identity. See :class:`~azure.identity.aio.ManagedIdentityCredential` for more details.
3. On Windows only: a user who has signed in with a Microsoft application, such as Visual Studio. This requires a
value for the environment variable ``AZURE_USERNAME``. See
:class:`~azure.identity.aio.SharedTokenCacheCredential` for more details.
"""

def __init__(self, **kwargs):
super().__init__(EnvironmentCredential(**kwargs), ManagedIdentityCredential(**kwargs))
credentials = [EnvironmentCredential(**kwargs), ManagedIdentityCredential(**kwargs)]

# SharedTokenCacheCredential is part of the default only on supported platforms, when $AZURE_USERNAME has a
# value (because the cache may contain tokens for multiple identities and we can only choose one arbitrarily
# without more information from the user), and when $AZURE_PASSWORD has no value (because when $AZURE_USERNAME
# and $AZURE_PASSWORD are set, EnvironmentCredential will be used instead)
if (
SharedTokenCacheCredential.supported()
and EnvironmentVariables.AZURE_USERNAME in os.environ
and EnvironmentVariables.AZURE_PASSWORD not in os.environ
):
credentials.append(
SharedTokenCacheCredential(username=os.environ.get(EnvironmentVariables.AZURE_USERNAME), **kwargs)
)

super().__init__(*credentials)


__all__ = [
Expand All @@ -33,4 +56,5 @@ def __init__(self, **kwargs):
"EnvironmentCredential",
"ManagedIdentityCredential",
"ChainedTokenCredential",
"SharedTokenCacheCredential",
]
31 changes: 30 additions & 1 deletion sdk/identity/azure-identity/azure/identity/aio/_authn_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
import time
from typing import Any, Dict, Iterable, Mapping, Optional

from msal import TokenCache
from azure.core import Configuration
from azure.core.credentials import AccessToken
from azure.core.exceptions import ClientAuthenticationError
from azure.core.pipeline import AsyncPipeline
from azure.core.pipeline.policies.distributed_tracing import DistributedTracingPolicy
from azure.core.pipeline.policies import (
AsyncRetryPolicy,
ContentDecodePolicy,
HTTPPolicy,
NetworkTraceLoggingPolicy,
ProxyPolicy,
)
from azure.core.pipeline.policies.distributed_tracing import DistributedTracingPolicy
from azure.core.pipeline.transport import AsyncHttpTransport
from azure.core.pipeline.transport.requests_asyncio import AsyncioRequestsTransport

Expand Down Expand Up @@ -61,6 +63,33 @@ async def request_token(
token = self._deserialize_and_cache_token(response=response, scopes=scopes, request_time=request_time)
return token

async def obtain_token_by_refresh_token(self, scopes: Iterable[str], username: str) -> Optional[AccessToken]:
"""Acquire an access token using a cached refresh token. Returns ``None`` when that fails, or the cache has no
refresh token. This is only used by SharedTokenCacheCredential and isn't robust enough for anything else."""

# find account matching username
accounts = self._cache.find(TokenCache.CredentialType.ACCOUNT, query={"username": username})
for account in accounts:
# try each refresh token that might work, return the first access token acquired
for token in self.get_refresh_tokens(scopes, account):
# currently we only support login.microsoftonline.com, which has an alias login.windows.net
# TODO: this must change to support sovereign clouds
environment = account.get("environment")
if not environment or (environment not in self._auth_url and environment != "login.windows.net"):
continue

request = self.get_refresh_token_grant_request(token, scopes)
request_time = int(time.time())
response = await self._pipeline.run(request, stream=False)
try:
return self._deserialize_and_cache_token(
response=response, scopes=scopes, request_time=request_time
)
except ClientAuthenticationError:
continue

return None

@staticmethod
def _create_config(**kwargs: Mapping[str, Any]) -> Configuration:
config = Configuration(**kwargs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
from .exception_wrapper import wrap_exceptions

__all__ = ["wrap_exceptions"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import functools

from azure.core.exceptions import ClientAuthenticationError


def wrap_exceptions(fn):
"""Prevents leaking exceptions defined outside azure-core by raising ClientAuthenticationError from them."""

@functools.wraps(fn)
async def wrapper(*args, **kwargs):
try:
result = await fn(*args, **kwargs)
return result
except ClientAuthenticationError:
raise
except Exception as ex: # pylint:disable=broad-except
auth_error = ClientAuthenticationError(message="Authentication failed: {}".format(ex))
raise auth_error from ex

return wrapper
Loading