Menu

Access private blobs with user credentials using Azure Function and App Service authentication

I recently came across a requirement of an easy way of downloading blobs from Azure Storage Account and performing authorization as a user on the Storage Account level. Having read about App Service authentication, I wanted to try it out in this scenario. This enables the capability of creating downloadable links to blobs that are authorized as the requesting user on Storage Account level.

App Service authentication greatly simplifies the authentication flow as the feature is integrated to the platform and takes care of authentication with federated identities which supports multiple providers. The configuration is really simple and it can be done using Azure Portal.

When the user opens the link to the Function App endpoint, the user is authenticated for the application. Function App then acts on behalf of the user to request access to Storage Account to generate a SAS URI which is then returned as response headers to automatically redirect the user to the created SAS URI.

High level solution diagram
High level solution diagram

These are the bits and pieces used for building the solution:

  • Function App for hosting the client request endpoint and for performing user authentication and authorization.
  • Azure AD application for App Service authentication configuration.
  • Storage Account for storing blob files.

This is the flow for requesting a single blob using App Service authentication:

  1. Authenticate the client request in App Service.
  2. Get access token for the application as the requesting user.
  3. Request access to Storage Account using on-behalf-of credentials and access token.
  4. Get blob data.
  5. Create blob SAS URI using user delegation key.

Azure AD Application

The easiest way to create the application is to use the App Service authentication wizard from Azure Function App Authentication section.

Identity provider wizard
Identity provider wizard

We start by selecting Authentication settings from Azure Portal and clicking Add identity provider.

Identity provider details
Identity provider details

We need to configure the application to use Microsoft identity provider which basically means Azure Active Directory. In this specific scenario I chose to require authentication from users only from the current tenant. The wizard configures the application in Azure AD for the authentication flow, including redirect URIs.

Authentication settings can also be deployed using bicep templates. There's an example of such a simple configuration below. In this case, Azure AD application would need to be created manually.

We'll not get into details of it here but feel free to experiment yourself with it. More information on official Microsoft documentation here.


    resource symbolicname 'Microsoft.Web/sites/config@2022-03-01' = {
      name: 'authsettingsV2'
      kind: 'string'
      parent: functionApp
      properties: {
        globalValidation: {
          requireAuthentication: true
          redirectToProvider: 'Microsoft'
        }
        identityProviders: {
          azureActiveDirectory: {
            enabled: true
            isAutoProvisioned: false
            registration: {
              clientId: clientPrincipalId
              clientSecretSettingName: 'AzureAdOptions__ClientSecret'
              openIdIssuer: 'https://sts.windows.net/${tenant().tenantId}/v2.0'
            }
            validation: {
              allowedAudiences: [
                'api://${clientPrincipalId}'
              ]
            }
            login: {
              loginParameters: ['scope=openid profile email offline_access']
            }
          }
        }
        login: {
          tokenStore: {
            enabled: true
            tokenRefreshExtensionHours: 72
          }      
        }
        httpSettings: {
          requireHttps: true
        }
      }
    }

In addition to creating the app to Azure AD, you need to create a client secret for the app to be able to request access tokens. Client secrets are created in Azure AD.

Function App

I used the configuration below for creating the Function App. I wanted to deploy my function in isolated mode but you can also choose to use "dotnet" runtime.

New Function App configuration
New Function App configuration

Function endpoint uses wildcard route to be able to access any blob regardless of the folder structure. I implemented a safety mechanism to only allow downloading blobs from preconfigured containers but that is up to you if such a feature is needed.


    [Function("BlobFunction")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "download/{container}/{*path}")] HttpRequestData req, string container, string path)
    {
        _logger.LogInformation($"requesting SAS URI for {path} in {container}");

        if (!ValidateConfiguration())
        {
            _logger.LogError($"Invalid configuration!");
            return req.CreateResponse(HttpStatusCode.BadRequest);
        }

        string[] apiScopes = { _azureAdOptions.Scope };

        // Create confidential client application for requesting access tokens
        var app = ConfidentialClientApplicationBuilder.Create(_azureAdOptions.ClientId)
              .WithTenantId(_azureAdOptions.Tenant)
              .WithClientSecret(_azureAdOptions.ClientSecret)
              .Build();

        var headers = req.Headers;
        if (!headers.TryGetValues("X-MS-TOKEN-AAD-ID-TOKEN", out var token))
        {
            _logger.LogError($"Missing auth header");
            return req.CreateResponse(HttpStatusCode.Unauthorized);
        }

        // UserAssertion object is required to act on behalf of the user
        var userAssertion = new UserAssertion(token.First());
        var result = await app.AcquireTokenOnBehalfOf(apiScopes, userAssertion).ExecuteAsync();

        var accessToken = result.AccessToken;
        if (accessToken == null)
        {
            _logger.LogError("Access Token could not be acquired");
            return req.CreateResponse(HttpStatusCode.Unauthorized);
        }

        var client = GetBlobServiceClient(accessToken);

        // We need user delegation key to generate blob SAS uri
        var userDelegationKey = await client.GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(15));
        var allowedContainers = Environment.GetEnvironmentVariable("AllowedContainers").Split(",");

        if (!allowedContainers.Contains(container))
        {
            _logger.LogError($"Container {container} not allowed");
            return req.CreateResponse(HttpStatusCode.Unauthorized);
        }

        var containerClient = client.GetBlobContainerClient(container);
        if (!await containerClient.ExistsAsync())
        {
            _logger.LogError($"Container {container} not found");
            return req.CreateResponse(HttpStatusCode.NotFound);
        }

        var blobClient = containerClient.GetBlobClient(path);
        if (!await blobClient.ExistsAsync())
        {
            _logger.LogError($"Blob {path} not found in container {container}");
            return req.CreateResponse(HttpStatusCode.NotFound);
        }

        // Generate SAS uri using user delegation key
        var sasUri = blobClient.GetSasUri(userDelegationKey);

        // Return the generated uri in response headers for automatic redirection
        var response = req.CreateResponse(HttpStatusCode.Redirect);
        response.Headers.Add("Location", sasUri.ToString());

        return response;
    }

    private BlobServiceClient GetBlobServiceClient(string accessToken)
    {
        // Create on behalf of credentials from user's access token
        var onBehalfOfCredential = new OnBehalfOfCredential(_azureAdOptions.Tenant, _azureAdOptions.ClientId, _azureAdOptions.ClientSecret, accessToken);
        return new BlobServiceClient(new Uri(_blobServiceOptions.Url), onBehalfOfCredential);
    }

    private bool ValidateConfiguration()
    {
        return !string.IsNullOrWhiteSpace(_azureAdOptions.Tenant) &&
            !string.IsNullOrWhiteSpace(_azureAdOptions.ClientId) &&
            !string.IsNullOrWhiteSpace(_azureAdOptions.ClientSecret) &&
            !string.IsNullOrWhiteSpace(_azureAdOptions.Scope) &&
            !string.IsNullOrWhiteSpace(_blobServiceOptions.Url);
    }

App Service authentication uses specific headers to send the tokens along with the request. In this case, we need to extract the user id token from X-MS-TOKEN-AAD-ID-TOKEN header. We use that token to request an access token on behalf of the user as the initial request does not contain a valid one for our application. Access token is then used to authenticate against the Storage Account.

Generating the SAS URI requires a user delegation key for which the validity period was set to 15 minutes.

Extension class for generating the SAS URI is shown below.


    internal static class BlobClientExtensions
    {
        internal static Uri GetSasUri(this BlobClient client, UserDelegationKey userDelegationKey)
        {
            var sasBuilder = new BlobSasBuilder()
            {
                BlobContainerName = client.BlobContainerName,
                BlobName = client.Name,
                Resource = "b",
                StartsOn = DateTimeOffset.UtcNow,
                ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
            };

            sasBuilder.SetPermissions(BlobSasPermissions.Read);

            var blobUriBuilder = new BlobUriBuilder(client.Uri)
            {
                Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, client.AccountName),
            };

            return blobUriBuilder.ToUri();
        }
    }

We also need a configuration file(local.settings.json) for local development/testing. Replace values from the created app registration.


    {
      "IsEncrypted": false,
      "Values": {
        "AzureWebJobsStorage": "UseDevelopmentStorage=true",
        "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
        "APPLICATIONINSIGHTS_CONNECTION_STRING": "***************************************",
        "AllowedContainers": "***",
        "AzureAdOptions:Tenant": "***********************************",
        "AzureAdOptions:ClientId": "************************************",
        "AzureAdOptions:ClientSecret": "****************************************",
        "AzureAdOptions:Scope": "api://*************************************/user_impersonation",
        "BlobServiceOptions:Url": "https://**********.blob.core.windows.net/"
      }
    }

Storage Account

The final thing is to add access to Storage Account files for the requesting users. To enable reading and creating SAS URIs for blob files, we need Storage Blob Data Contributor role for the Storage Account. This is enough for us to test our scenario while a better practice would be to apply the role to AD groups rather than individual users.

Testing

That's it, we're all set! Upload a file to the Storage Account and point the link to the function app endpoint with the equivalent container/path configuration to test the solution.