Repository: google/cloud-forensics-utils

Issue:
Add support for AzureCliCredential auth
Add support for `az login` style authentication. 

LCF currently uses DefaultAzureCredential which supports AzureCliCredential but the [GetCredentials](https://github.com/google/cloud-forensics-utils/blob/e010499745273e10bda3f030c734a3469c0a0296/libcloudforensics/providers/azure/internal/common.py#L48)  function bails out if it doesn't find a credentials file or environment variables.

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/332?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) Report
> :exclamation: No coverage uploaded for pull request base (`main@8e0302a`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/google/cloud-forensics-utils/pull/332/graphs/tree.svg?width=650&height=150&src=pr&token=zZkPno3ah0&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)](https://codecov.io/gh/google/cloud-forensics-utils/pull/332?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)

```diff
@@           Coverage Diff           @@
##             main     #332   +/-   ##
=======================================
  Coverage        ?   64.28%           
=======================================
  Files           ?       33           
  Lines           ?     2411           
  Branches        ?        0           
=======================================
  Hits            ?     1550           
  Misses          ?      861           
  Partials        ?        0           
```

| Flag | Coverage Δ | |
|---|---|---|
| nosetests | `64.28% <0.00%> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#carryforward-flags-in-the-pull-request-comment) to find out more.


------

[Continue to review full report at Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/332?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/332?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Last update [8e0302a...d8437c3](https://codecov.io/gh/google/cloud-forensics-utils/pull/332?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).


Patch:
diff --git a/.gitignore b/.gitignore
index bc709a3..5fcabb9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ dist/
 # virtualenv
 venv/
 ENV/
+
+# VSCode
+.vscode/
\ No newline at end of file
diff --git a/libcloudforensics/providers/azure/internal/common.py b/libcloudforensics/providers/azure/internal/common.py
index 9179c0a..82a142f 100644
--- a/libcloudforensics/providers/azure/internal/common.py
+++ b/libcloudforensics/providers/azure/internal/common.py
@@ -23,7 +23,7 @@ from typing import Any, List, Dict, Optional, TYPE_CHECKING, Tuple
 
 from azure.identity import DefaultAzureCredential
 
-
+from libcloudforensics import logging_utils
 from libcloudforensics import errors
 
 if TYPE_CHECKING:
@@ -31,6 +31,9 @@ if TYPE_CHECKING:
   # the following cyclic import, as it it only used for type hints
   from libcloudforensics.providers.azure.internal import compute  # pylint: disable=cyclic-import
 
+logging_utils.SetUpLogger(__name__)
+logger = logging_utils.GetLogger(__name__)
+
 # pylint: disable=line-too-long
 # See https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules
 # pylint: enable=line-too-long
@@ -45,18 +48,12 @@ DEFAULT_DISK_COPY_PREFIX = 'evidence'
 UBUNTU_1804_SKU = '18.04-LTS'
 
 
-def GetCredentials(profile_name: Optional[str] = None
-                   ) -> Tuple[str, DefaultAzureCredential]:
-  # pylint: disable=line-too-long
-  """Get Azure credentials.
+def _ParseCredentialsFile(profile_name: str) -> Dict[str, Any]:
+  """Parse Azure credentials.json file.
 
   Args:
     profile_name (str): A name for the Azure account information to retrieve.
-        If not provided, the default behavior is to look for Azure credential
-        information in environment variables as explained in https://docs.microsoft.com/en-us/azure/developer/python/azure-sdk-authenticate
-        If provided, then the library will look into
-        ~/.azure/credentials.json for the account information linked to
-        profile_name. The .json file should have the following format:
+        The .json file should have the following format:
 
         {
           'profile_name': {
@@ -81,8 +78,7 @@ def GetCredentials(profile_name: Optional[str] = None
         there instead of in ~/.azure/credentials.json.
 
   Returns:
-    Tuple[str, DefaultAzureCredential]: Subscription ID and
-        corresponding Azure credentials.
+    Dict[str, str]: A dict containing the required account_info fields.
 
   Raises:
     CredentialsConfigurationError: If there are environment variables that
@@ -90,20 +86,6 @@ def GetCredentials(profile_name: Optional[str] = None
     FileNotFoundError: If the credentials file is not found.
     InvalidFileFormatError: If the credentials file couldn't be parsed.
   """
-  # pylint: enable=line-too-long
-  if not profile_name:
-    subscription_id = os.getenv('AZURE_SUBSCRIPTION_ID')
-    client_id = os.getenv("AZURE_CLIENT_ID")
-    secret = os.getenv("AZURE_CLIENT_SECRET")
-    tenant = os.getenv("AZURE_TENANT_ID")
-    if not (subscription_id and client_id and secret and tenant):
-      raise errors.CredentialsConfigurationError(
-          'Please make sure you defined the following environment variables: '
-          '[AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, '
-          'AZURE_TENANT_ID].', __name__)
-    # DefaultAzureCredential will pick up variables automatically.
-    return subscription_id, DefaultAzureCredential()
-
   path = os.getenv('AZURE_CREDENTIALS_PATH')
   if not path:
     path = os.path.expanduser('~/.azure/credentials.json')
@@ -116,7 +98,7 @@ def GetCredentials(profile_name: Optional[str] = None
 
   with open(path) as profiles:
     try:
-      account_info = json.load(profiles).get(profile_name)
+      account_info: Dict[str, Any] = json.load(profiles).get(profile_name)
     except ValueError as exception:
       raise errors.InvalidFileFormatError(
           'Could not decode JSON file. Please verify the file format:'
@@ -133,12 +115,104 @@ def GetCredentials(profile_name: Optional[str] = None
           'file should contain at least the following: {0:s}'.format(
               ', '.join(required_entries)), __name__)
 
-    # Set environment variables so that DefaultAzureCredentail can pick them up.
+  return account_info
+
+
+def _CheckAzureCliCredentials() -> Optional[str]:
+  """Test if AzureCliCredentials are configured, returning the subscription
+  id if successful.
+
+  Returns:
+    str: the subscription_id of the credentials if properly configured or else
+      None.
+
+  Raises:
+    CredentialsConfigurationError: If AzureCliCredentials are configured but
+      the active subscription could not be determined.
+  """
+  tokens = None
+
+  config_dir = os.getenv('AZURE_CONFIG_DIR')
+  if not config_dir:
+    config_dir = os.path.expanduser('~/.azure/')
+
+  tokens_path = os.path.join(config_dir, 'accessTokens.json')
+  profile_path = os.path.join(config_dir, 'azureProfile.json')
+
+  if not os.path.exists(tokens_path) or not os.path.exists(profile_path):
+    return None
+
+  with open(tokens_path, encoding='utf-8-sig') as tokens_fd:
+    tokens = json.load(tokens_fd)
+
+  # If tokens are not found then Azure CLI auth is not configured.
+  if not tokens:
+    return None
+
+  with open(profile_path, encoding='utf-8-sig') as profile_fd:
+    profile = json.load(profile_fd)
+
+    for subscription in profile['subscriptions']:
+      if subscription['isDefault']:
+        return str(subscription["id"])
+
+    raise errors.CredentialsConfigurationError(
+        'AzureCliCredentials tokens found but could not determine active '
+        'subscription. No "isDefault" set in "{0:s}"'.format(config_dir),
+        __name__)
+
+
+def GetCredentials(profile_name: Optional[str] = None
+                   ) -> Tuple[str, DefaultAzureCredential]:
+  # pylint: disable=line-too-long
+  """Get Azure credentials, trying three different methods:
+
+  1. If profile_name is provided it will attempt to parse credentials from a
+     credentials.json file, failing if this raises an exception.
+  2. Environment variables as per
+     https://docs.microsoft.com/en-us/azure/developer/python/azure-sdk-authenticate
+  3. Azure CLI credentials.
+
+  Args:
+    profile_name (str): A name for the Azure account information to retrieve.
+        If provided, then the library will look into ~/.azure/credentials.json
+        for the account information linked to profile_name.
+
+  Returns:
+    Tuple[str, DefaultAzureCredential]: Subscription ID and
+        corresponding Azure credentials.
+
+  Raises:
+    CredentialsConfigurationError: If none of the credential methods work.
+  """
+  # pylint: enable=line-too-long
+  if profile_name:
+    account_info = _ParseCredentialsFile(profile_name)
+    # Set environment variables for DefaultAzureCredentials.
     os.environ['AZURE_SUBSCRIPTION_ID'] = account_info['subscriptionId']
     os.environ['AZURE_CLIENT_ID'] = account_info['clientId']
     os.environ['AZURE_CLIENT_SECRET'] = account_info['clientSecret']
     os.environ['AZURE_TENANT_ID'] = account_info['tenantId']
-    return account_info['subscriptionId'], DefaultAzureCredential()
+
+  # Check if environment variables are already set for DefaultAzureCredentials.
+  subscription_id = os.getenv('AZURE_SUBSCRIPTION_ID')
+  client_id = os.getenv("AZURE_CLIENT_ID")
+  secret = os.getenv("AZURE_CLIENT_SECRET")
+  tenant = os.getenv("AZURE_TENANT_ID")
+
+  if not (subscription_id and client_id and secret and tenant):
+    logger.info('EnvironmentCredentials unavailable, falling back to '
+          'AzureCliCredentials.')
+    # Will be automatically picked up by DefaultAzureCredential if configured.
+    subscription_id = _CheckAzureCliCredentials()
+
+  if not subscription_id:
+    raise errors.CredentialsConfigurationError(
+        'No supported credentials found. If using environment variables '
+        'please make sure to define: [AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_ID, '
+        'AZURE_CLIENT_SECRET, AZURE_TENANT_ID].', __name__)
+
+  return subscription_id, DefaultAzureCredential()
 
 
 def ExecuteRequest(


Test patch:
diff --git a/tests/providers/azure/azure_mocks.py b/tests/providers/azure/azure_mocks.py
index e4c403a..a93eba5 100644
--- a/tests/providers/azure/azure_mocks.py
+++ b/tests/providers/azure/azure_mocks.py
@@ -153,3 +153,6 @@ MOCK_METRIC_OPERATION_VALUE = mock.Mock(timeseries=[mock.Mock(
     data=[mock.Mock(time_stamp='fake-time-stamp', total='fake-value')])])
 MOCK_METRIC_OPERATION_VALUE.name = mock.Mock(value='fake-metric')
 MOCK_METRIC_OPERATION = mock.Mock(value=[MOCK_METRIC_OPERATION_VALUE])
+
+AZURE_CONFIG_DIR = 'scripts/test_azure_config_dir/'
+EMPTY_AZURE_CONFIG_DIR = 'scripts/test_empty_azure_config_dir/'
diff --git a/tests/providers/azure/internal/test_common.py b/tests/providers/azure/internal/test_common.py
index d48cdc3..40eac4b 100644
--- a/tests/providers/azure/internal/test_common.py
+++ b/tests/providers/azure/internal/test_common.py
@@ -28,6 +28,15 @@ class AZCommonTest(unittest.TestCase):
   """Test Azure common file."""
   # pylint: disable=line-too-long
 
+  # Stop tests from picking up Azure creds from host.
+  @typing.no_type_check
+  def setUp(self):
+    tests_dir = os.path.dirname(
+        os.path.dirname(os.path.dirname(os.path.dirname(
+            os.path.realpath(__file__)))))
+    os.environ['AZURE_CONFIG_DIR'] = os.path.join(
+        tests_dir, azure_mocks.EMPTY_AZURE_CONFIG_DIR)
+
   @typing.no_type_check
   def testGenerateDiskName(self):
     """Test that disk names are correclty generated.
@@ -79,9 +88,9 @@ class AZCommonTest(unittest.TestCase):
       _, _ = common.GetCredentials()
       mock_azure_credentials.assert_not_called()
     self.assertEqual(
-        'Please make sure you defined the following environment variables: '
-        '[AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, '
-        'AZURE_TENANT_ID].', str(error.exception))
+        'No supported credentials found. If using environment variables '
+        'please make sure to define: [AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_ID, '
+        'AZURE_CLIENT_SECRET, AZURE_TENANT_ID].', str(error.exception))
 
   @mock.patch('azure.identity._credentials.default.DefaultAzureCredential.__init__')
   @typing.no_type_check
@@ -156,3 +165,17 @@ class AZCommonTest(unittest.TestCase):
         'Profile name incomplete_profile_name not found in credentials file '
         '{0:s}'.format(
             os.environ['AZURE_CREDENTIALS_PATH']), str(error.exception))
+
+  @mock.patch('azure.identity._credentials.default.DefaultAzureCredential.__init__')
+  @typing.no_type_check
+  def testGetCliCredentials(self, mock_azure_credentials):
+    """Test that AzureCliCredentials are properly parsed"""
+    mock_azure_credentials.return_value = None
+
+    os.environ['AZURE_CONFIG_DIR'] = os.path.join(
+        os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
+            os.path.realpath(__file__))))), azure_mocks.AZURE_CONFIG_DIR)
+
+    subscription_id, _ = common.GetCredentials()
+
+    self.assertEqual('12345678-1234-5678-1234-567812345678', subscription_id)
diff --git a/tests/scripts/test_azure_config_dir/accessTokens.json b/tests/scripts/test_azure_config_dir/accessTokens.json
new file mode 100644
index 0000000..bbd5256
--- /dev/null
+++ b/tests/scripts/test_azure_config_dir/accessTokens.json
@@ -0,0 +1,1 @@
+[{"tokenType":"Bearer","expiresIn":3599,"expiresOn":"2022-01-0100:00:00.000000","resource":"https://management.azure.com","accessToken":"NOTATOKEN","refreshToken":"NOTATOKEN","oid":"12345678-1234-5678-1234-567812345678","userId":"fakename@fakedomain.com","isMRRT":true,"_clientId":"12345678-1234-5678-1234-567812345678","_authority":"https://login.microsoftonline.com/12345678-1234-5678-1234-567812345678"}]
\ No newline at end of file
diff --git a/tests/scripts/test_azure_config_dir/azureProfile.json b/tests/scripts/test_azure_config_dir/azureProfile.json
new file mode 100644
index 0000000..2b75e1c
--- /dev/null
+++ b/tests/scripts/test_azure_config_dir/azureProfile.json
@@ -0,0 +1,1 @@
+{"installationId": "12345678-1234-5678-1234-567812345678", "subscriptions": [{"id": "12345678-1234-5678-1234-567812345678", "name": "Fake Subscription 1", "state": "Enabled", "user": {"name": "fakename@fakedomain.com", "type": "user"}, "isDefault": true, "tenantId": "12345678-1234-5678-1234-567812345678", "environmentName": "AzureCloud", "homeTenantId": "12345678-1234-5678-1234-567812345678", "managedByTenants": []}]}
\ No newline at end of file