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
67 changes: 36 additions & 31 deletions src/azure-cli-core/azure/cli/core/_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,17 +284,16 @@ def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=N

def login_in_cloud_shell(self):
import jwt
from azure.cli.core.auth.adal_authentication import MSIAuthenticationWrapper
from .auth.msal_credentials import CloudShellCredential

msi_creds = MSIAuthenticationWrapper(resource=self.cli_ctx.cloud.endpoints.active_directory_resource_id)
token_entry = msi_creds.token
token = token_entry['access_token']
logger.info('MSI: token was retrieved. Now trying to initialize local accounts...')
cred = CloudShellCredential()
token = cred.get_token(*self._arm_scope).token
logger.info('Cloud Shell token was retrieved. Now trying to initialize local accounts...')
decode = jwt.decode(token, algorithms=['RS256'], options={"verify_signature": False})
tenant = decode['tid']

subscription_finder = SubscriptionFinder(self.cli_ctx)
subscriptions = subscription_finder.find_using_specific_tenant(tenant, msi_creds)
subscriptions = subscription_finder.find_using_specific_tenant(tenant, cred)
if not subscriptions:
raise CLIError('No subscriptions were found in the cloud shell')
user = decode.get('unique_name', 'N/A')
Expand Down Expand Up @@ -351,11 +350,19 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N

managed_identity_type, managed_identity_id = Profile._try_parse_msi_account_name(account)

# Cloud Shell is just a system assignment managed identity
if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID):
managed_identity_type = MsiAccountTypes.system_assigned
# Cloud Shell
from .auth.msal_credentials import CloudShellCredential
from azure.cli.core.auth.credential_adaptor import CredentialAdaptor
cs_cred = CloudShellCredential()
# The cloud shell credential must be wrapped by CredentialAdaptor so that it can work with Track 1 SDKs.
cred = CredentialAdaptor(cs_cred, resource=resource)

if managed_identity_type is None:
elif managed_identity_type:
# managed identity
cred = MsiAccountTypes.msi_auth_factory(managed_identity_type, managed_identity_id, resource)

else:
# user and service principal
external_tenants = []
if aux_tenants:
Expand All @@ -375,9 +382,7 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N
cred = CredentialAdaptor(credential,
auxiliary_credentials=external_credentials,
resource=resource)
else:
# managed identity
cred = MsiAccountTypes.msi_auth_factory(managed_identity_type, managed_identity_id, resource)

return (cred,
str(account[_SUBSCRIPTION_ID]),
str(account[_TENANT_ID]))
Expand All @@ -397,27 +402,27 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No

account = self.get_subscription(subscription)

identity_type, identity_id = Profile._try_parse_msi_account_name(account)
if identity_type:
managed_identity_type, managed_identity_id = Profile._try_parse_msi_account_name(account)

if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID):
# Cloud Shell
if tenant:
raise CLIError("Tenant shouldn't be specified for Cloud Shell account")
from .auth.msal_credentials import CloudShellCredential
cred = CloudShellCredential()

elif managed_identity_type:
# managed identity
if tenant:
raise CLIError("Tenant shouldn't be specified for managed identity account")
from .auth.util import scopes_to_resource
msi_creds = MsiAccountTypes.msi_auth_factory(identity_type, identity_id,
scopes_to_resource(scopes))
sdk_token = msi_creds.get_token(*scopes)
elif in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID):
# Cloud Shell, which is just a system-assigned managed identity.
if tenant:
raise CLIError("Tenant shouldn't be specified for Cloud Shell account")
from .auth.util import scopes_to_resource
msi_creds = MsiAccountTypes.msi_auth_factory(MsiAccountTypes.system_assigned, identity_id,
scopes_to_resource(scopes))
sdk_token = msi_creds.get_token(*scopes)
cred = MsiAccountTypes.msi_auth_factory(managed_identity_type, managed_identity_id,
scopes_to_resource(scopes))

else:
credential = self._create_credential(account, tenant)
sdk_token = credential.get_token(*scopes)
cred = self._create_credential(account, tenant)

sdk_token = cred.get_token(*scopes)
# Convert epoch int 'expires_on' to datetime string 'expiresOn' for backward compatibility
# WARNING: expiresOn is deprecated and will be removed in future release.
import datetime
Expand All @@ -429,11 +434,11 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No
'expiresOn': expiresOn # datetime string, like "2020-11-12 13:50:47.114324"
}

# (tokenType, accessToken, tokenEntry)
creds = 'Bearer', sdk_token.token, token_entry
# Build a tuple of (token_type, token, token_entry)
token_tuple = 'Bearer', sdk_token.token, token_entry
Copy link
Member Author

Choose a reason for hiding this comment

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

The name creds conflicts with the "credential" concept (msal_credentials.py), so I rename it to better reflect its content.


# (cred, subscription, tenant)
return (creds,
# Return a tuple of (token_tuple, subscription, tenant)
return (token_tuple,
None if tenant else str(account[_SUBSCRIPTION_ID]),
str(tenant if tenant else account[_TENANT_ID]))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
# Use MSAL to get VM SSH certificate
import msal
from .util import check_result, build_sdk_access_token
from .identity import AZURE_CLI_CLIENT_ID
from .constants import AZURE_CLI_CLIENT_ID
app = msal.PublicClientApplication(
AZURE_CLI_CLIENT_ID, # Use a real client_id, so that cache would work
# TODO: This PoC does not currently maintain a token cache;
Expand Down
6 changes: 6 additions & 0 deletions src/azure-cli-core/azure/cli/core/auth/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46'
3 changes: 1 addition & 2 deletions src/azure-cli-core/azure/cli/core/auth/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@
from knack.util import CLIError
from msal import PublicClientApplication, ConfidentialClientApplication

from .constants import AZURE_CLI_CLIENT_ID
from .msal_credentials import UserCredential, ServicePrincipalCredential
from .persistence import load_persisted_token_cache, file_extensions, load_secret_store
from .util import check_result

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

# Service principal entry properties. Names are taken from OAuth 2.0 client credentials flow parameters:
# https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow
_TENANT = 'tenant'
Expand Down
23 changes: 23 additions & 0 deletions src/azure-cli-core/azure/cli/core/auth/msal_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from knack.util import CLIError
from msal import PublicClientApplication, ConfidentialClientApplication

from .constants import AZURE_CLI_CLIENT_ID
Copy link
Member Author

Choose a reason for hiding this comment

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

As identity.py imports from msal_credentials.py, AZURE_CLI_CLIENT_ID is moved from identity.py to constants.py to avoid circular import (msal_credentials.py imports from identity.py).

Although circular imports can also be avoided by importing modules within a function or method, this is not as good as the current solution.

from .util import check_result, build_sdk_access_token

logger = get_logger(__name__)
Expand Down Expand Up @@ -108,3 +109,25 @@ def get_token(self, *scopes, **kwargs):
result = self._msal_app.acquire_token_for_client(list(scopes), **kwargs)
check_result(result)
return build_sdk_access_token(result)


class CloudShellCredential: # pylint: disable=too-few-public-methods
# Cloud Shell acts as a "broker" to obtain access token for the user account, so even though it uses
# managed identity protocol, it returns a user token.
# That's why MSAL uses acquire_token_interactive to retrieve an access token in Cloud Shell.
# See https://github.com/Azure/azure-cli/pull/29637

def __init__(self):
self._msal_app = PublicClientApplication(
AZURE_CLI_CLIENT_ID, # Use a real client_id, so that cache would work
# TODO: We currently don't maintain an MSAL token cache as Cloud Shell already has its own token cache.
# Ideally we should also use an MSAL token cache.
# token_cache=...
)
Comment on lines +121 to +126
Copy link
Member Author

Choose a reason for hiding this comment

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

I personally don't agree with MSAL's design and explanation (#29637 (comment)). Squeezing Cloud Shell authentication into PublicClientApplication looks awkward to me.

Even though MSAL uses PublicClientApplication for Cloud Shell authentication, the way of calling it is very different from user or service principal authentication which talks to eSTS. For Cloud Shell authentication, authority should not be passed, but for user or service principal authentication, authority is mandatory, causing inconsistent calling pattern.

Also, AZURE_CLI_CLIENT_ID is passed here only for caching purpose - it is not the app ID that is used to acquire the access token. The returned access token is actually for Azure Portal. Making the input and output contradictory.


def get_token(self, *scopes, **kwargs):
logger.debug("CloudShellCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs)
# kwargs is already sanitized by CredentialAdaptor, so it can be safely passed to MSAL
result = self._msal_app.acquire_token_interactive(list(scopes), prompt="none", **kwargs)
Comment on lines +130 to +131
Copy link
Member Author

Choose a reason for hiding this comment

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

kwargs sanitization logic is added by #30062.

check_result(result, scopes=scopes)
return build_sdk_access_token(result)
Loading