Rob
Rob

Reputation: 7237

Problems using MSI to authenticate to Azure BlobStorage

I am trying to connect my C# console app (.net core 2.1) to blob storage. I initialise the Blob Storage client in a couple of different ways. They are:

  1. Connection String - useful during development
  2. Service Principle - possibility for production deployment
  3. MSI Authentication - more secure, keys are automatically cycled for us

In my code, if the connection string is not explicitly set, I generate it using a service principle or MSI depending on app settings defined (sample initialisation code below). No matter which of the three ways I use, I will ultimately initialise the client using a connection string (either its explicitly set in case of 1. or generated with my code in case of 2. and 3.).

The code below works 100% fine for 1 (connection string) and 2 (service principle) BUT I am getting an error when attempting to achieve 3 (MSI).

When running locally I get this error:

The access token is from the wrong issuer 'https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/'. It must match the tenant 'https://sts.windows.net/{my-subscription-id}/' associated with this subscription. Please use the authority (URL) 'https://login.windows.net/{my-subscription-id}' to get the token. Note, if the subscription is transferred to another tenant there is no impact to the services, but information about new tenant could take time to propagate (up to an hour). If you just transferred your subscription and see this error message, please try back later.

With this, I have no idea where 'f8cdef31-a31e-4b4a-93e4-5f571e91255a' comes from, it could be the global Microsoft instance. I tried to mitigate this by running my code in a webjob within Azure which has MSI enabled to see if that works, I get:

System.AggregateException: One or more errors occurred. (An exception occurred during service connection, see inner exception for more detail) ---> System.Exception: An exception occurred during service connection, see inner exception for more detail ---> Microsoft.Rest.Azure.CloudException: The client '{my-subscription-id}' with object id '{my-subscription-id}' does not have authorization to perform action 'Microsoft.Storage/storageAccounts/read' over scope '/subscriptions/{my-subscription-id}'.

(note I have the MSI account set as 'owner' and 'Storage Account Key Operator' of blob storage)

I initialise the CloudStorageAccount client in the following way:

public void InitializeClient()
{
    // Always using the connection string, no matter how it's generated.
    if (ConnectionString.IsNullOrEmpty()) // if not already set, then build.
        ConnectionString = BuildStorageConnection().GetAwaiter().GetResult();

    CloudStorageAccount.TryParse(ConnectionString, out var storageAccount);

    if (storageAccount == null)
        throw new InvalidOperationException("Cannot find storage account");

    // CloudBlobClient that represents the Blob storage endpoint.
    _cloudBlobClient = storageAccount.CreateCloudBlobClient();
}

And build a connection string as follows:

internal async Task<string> BuildStorageConnection()
{
    try
    {
        string token = null;

        if (Config.UseMsi)
        {
            // Managed Service Identity (MSI) authentication.
            var provider = new AzureServiceTokenProvider();
            token = provider.GetAccessTokenAsync("https://management.azure.com/").GetAwaiter().GetResult();

            if (string.IsNullOrEmpty(token))
                throw new InvalidOperationException("Could not authenticate using Managed Service Identity");

            _expiryTime = DateTime.Now.AddDays(1);
        }
        else
        {
            // Service Principle authentication
            // Grab an authentication token from Azure.
            var context = new AuthenticationContext("https://login.windows.net/" + Config.TenantId);

            var credential = new ClientCredential(Config.AppId, Config.AppSecret);
            var tokenResult = context.AcquireTokenAsync("https://management.azure.com/", credential).GetAwaiter().GetResult();

            if (tokenResult == null || tokenResult.AccessToken == null)
                throw new InvalidOperationException($"Could not authenticate using Service Principle");

            _expiryTime = tokenResult.ExpiresOn;
            token = tokenResult.AccessToken;
        }

        // Set credentials and grab the authenticated REST client.
        var tokenCredentials = new TokenCredentials(token);

        var client = RestClient.Configure()
            .WithEnvironment(AzureEnvironment.AzureGlobalCloud)
            .WithLogLevel(HttpLoggingDelegatingHandler.Level.BodyAndHeaders)
            .WithCredentials(new AzureCredentials(tokenCredentials, tokenCredentials, string.Empty, AzureEnvironment.AzureGlobalCloud))
            .WithRetryPolicy(new RetryPolicy(new HttpStatusCodeErrorDetectionStrategy(), new FixedIntervalRetryStrategy(3, TimeSpan.FromMilliseconds(500))))
            .Build();

        // Authenticate against the management layer.
        var azureManagement = Azure.Authenticate(client, string.Empty).WithSubscription(Config.SubscriptionId);

        // Get the storage namespace for the passed in instance name.
        var storageNamespace = azureManagement.StorageAccounts.List().FirstOrDefault(n => n.Name == Config.StorageInstanceName);

        // If we cant find that name, throw an exception.
        if (storageNamespace == null)
        {
            throw new InvalidOperationException($"Could not find the storage instance {Config.StorageInstanceName} in the subscription with ID {Config.SubscriptionId}");
        }

        // Storage accounts use access keys - this will be used to build a connection string.
        var accessKeys = await storageNamespace.GetKeysAsync();

        // If the access keys are not found (not configured for some reason), throw an exception.
        if (accessKeys == null)
        {
            throw new InvalidOperationException($"Could not find access keys for the storage instance {Config.StorageInstanceName}");
        }

        // We just default to the first key.
        var key = accessKeys[0].Value;

        // Build and return the connection string.
        return $"DefaultEndpointsProtocol=https;AccountName={Config.StorageInstanceName};AccountKey={key};EndpointSuffix=core.windows.net";
    }
    catch (Exception e)
    {
        Logger?.LogError(e, "An exception occured during connection to blob storage");
        throw new Exception("An exception occurred during service connection, see inner exception for more detail", e);
    }
}

The main difference in how I'm getting the access token is that using service principle I have an authentication context and using MSI I do not. Is this effecting the scope of authentication? Any help and advice with this much appreciated!

Upvotes: 1

Views: 2190

Answers (1)

Rob
Rob

Reputation: 7237

Just realised how to resolve the issue above - changing the GetTokenAsync to have a second parameter of TenantId gives context to the authentication call.

Here's the code you will need:

token = await provider.GetAccessTokenAsync("https://management.azure.com/", Config.TenantId);

Upvotes: 2

Related Questions