Skip to main content

Command Palette

Search for a command to run...

A simple guide to securing Azure Storage in ASP.NET Core Minimal API's with Microsoft Entra ID and OpenID Connect (OIDC)

Updated
11 min readView as Markdown
 A simple guide to securing Azure Storage in ASP.NET Core Minimal API's with Microsoft Entra ID and OpenID Connect (OIDC)
S
From Synapse Analytics, Power BI, Spark, Microsoft Fabric,ASP.NET Core and recently Agentic AI on .NET I try to explore, learn and share all aspects of Microsoft Data Stack in this blog.

In this article, we'll take a deep dive into securing an ASP.NET Core Minimal API with Microsoft Entra ID and using it to access Azure ADLS Gen2 storage implementing Microsoft Entra ID for user sign-in through OpenID Connect (OIDC).

We'll explore how Microsoft Entra ID integrates with ASP.NET Core Minimal APIs, how access tokens are leveraged to authenticate requests and how Azure Storage authorizes those requests using Azure RBAC and Access Control Lists (ACLs).

As compared to other articles on the internet that only provide high-level overview on the topic, my article will focus on the actual implementation of authentication and authorization mechanisms involved for securing Azure services through Minimal API's by leveraging Microsoft Entra ID.

Unfortunately I wont go in-depth explaining all the underlying concepts involved. So before you begin please ensure that you should be familiar with the following concepts:

  • ASP.NET Core Minimal APIs

  • Microsoft Entra ID and OpenID Connect (OIDC)

  • Azure Storage (Blob Storage or Data Lake Storage Gen2)

  • Azure Role-Based Access Control (Azure RBAC)

  • Azure Storage Access Control Lists (ACLs)

A basic understanding of these technologies will help you get the most out of this article and follow the implementation steps details more easily.

Basic OAUTH 2.0 and OIDC flow for Microsoft Entra ID tokens.

The implementation in this article is pretty straightforward.

We'll build an ASP.NET Core Minimal API that authenticates users with Microsoft Entra ID and once authenticated it will securely accesses Azure ADLS Gen2 Storage through DataLakeServiceClient (i.e. return a list of all the directories in a given Azure ADLS Gen2 storage container) on behalf of the user.

I will use the following references from my other articles

The article JWT tokens in Minimal API's quoted above demonstrates how custom JWT tokens can be used to authorize access where requests were approved or denied based on defined authorization policies.

However, in this article we will leverage the access tokens generated by Microsoft Entra Id for a service principal and the delegated permissions granted to it . We then validate the token audience. Once the token is validated, the signed in user access is then checked for RBAC permissions granted for the given Azure ADLS Gen2 storage.

Flow

To add more context to the above flow, the scope used is

https://storage.azure.com/

SetUp

The ADLS storage has the following structure

The expectation from the code is that it should be capable of recursively traversing all directories within a given container. As shown in the screenshot above, the customers container contains directories that are nested up to three levels deep and this structure can be dynamic.

So , there are two users :

sachin.nandanwar@azureguru.net is assigned the necessary RBAC role accesses to the storage. Typically it has to be Storage Blob Data Owner role.

You could also grant Storage Blob Data Contributor role but the Storage Blob Data Owner role has POSIX access control (ACL access) that auto grants all the (r-w-x) privileges to the owner for all the underlying objects under the container.

For instance in the following screenshot we can see that the owner i.e. the Storage Blob Data Owner was auto assigned the (r-w-x) access .

sachin_nandanwar@azureguru.net is not assigned any RBAC role.

Create a Service Principal and grant the Azure Storage delegated permissions. I created one called ADLS GEN2 Service Principal.

Ensure that you the Scalar set up is configured in your ASP.NET core project. For more details refer to my following article

https://www.azureguru.net/customize-scalar-UI-for-net-api

Also ensure that you have a thorough understanding of Minimal API's and implementation of ClaimPrincipal, Authentication and Authorization for Minimal API's. Please refer to my article to have a better understanding of the topic.

https://www.azureguru.net/implementing-jwt-tokens-in-minimal-api-net-core

You can ignore the custom JWToken aspect from the above article as in that article the focus was on creating custom JWTokens.

Code

Add the following references to your ASP.NET core project

dotnet add package Azure.Core;
dotnet add package Azure.Storage.Files.DataLake;
dotnet add package Microsoft.IdentityModel.Tokens;
dotnet add package Scalar.AspNetCore;
dotnet add package System.Security.Claims;

First, we define an Authentication class that validates the user's sign-in and requested scopes with Microsoft Entra ID and then returns a JWT token.

Unlike implementing custom JWT tokens which I demonstrated in my earlier article here, with Microsoft Entra ID-issued tokens there is no need for generating, signing, rotating or validating JWTs ourselves. Microsoft Entra ID auto handles all these aspects at its end.

Authentication.cs >>

using Microsoft.Identity.Client;
using System.IdentityModel.Tokens.Jwt;

namespace Security
{
    internal class Authentication
    {
        public static string clientId = "Service Principal Client Id";
        private static string[] scopes = { "https://storage.azure.com/.default" };
        private static string Authority = "https://login.microsoftonline.com/organizations";
        private static string RedirectURI = "http://localhost";

        public async static Task<JwtSecurityToken> ReturnAuthenticationResult()
        {
            string AccessToken;
            IPublicClientApplication PublicClientApplication = PublicClientApplicationBuilder
                .Create(clientId)
                .WithAuthority(Authority)
                .WithCacheOptions(CacheOptions.EnableSharedCacheOptions)
                .WithRedirectUri(RedirectURI)
                .Build();

            var accounts = await PublicClientApplication.GetAccountsAsync();
            AuthenticationResult result;

            try
            {
                result = await PublicClientApplication
                    .AcquireTokenSilent(scopes, accounts.First())
                    .ExecuteAsync()
                    .ConfigureAwait(false);
            }
            catch
            {
                result = await PublicClientApplication
                    .AcquireTokenInteractive(scopes)
                    .ExecuteAsync()
                    .ConfigureAwait(false);
            }

            JwtSecurityToken token = new JwtSecurityToken(result.AccessToken);
            return token;
        }
    }
}

For brevity I have defined ClientId and other details in variables. Ideally they should be placed in a config file and their values fetched from there.

AccessTokenCredential.cs >>

using Azure.Core;
using System.IdentityModel.Tokens.Jwt;

namespace AccesTokenCredentials
{
    public class AccessTokenCredential : Azure.Identity.ClientSecretCredential
    {
        public AccessTokenCredential(string accessToken)
        {
            AccessToken = accessToken;
        }

        private string AccessToken;

        public AccessToken FetchAccessToken()
        {
            JwtSecurityToken token = new JwtSecurityToken(AccessToken);
            return new AccessToken(AccessToken, token.ValidTo);
        }

        public override ValueTask<AccessToken> GetTokenAsync(
            TokenRequestContext requestContext,
            CancellationToken cancellationToken
        )
        {
            return new ValueTask<AccessToken>(FetchAccessToken());
        }

        public override AccessToken GetToken(
            TokenRequestContext requestContext,
            CancellationToken cancellationToken
        )
        {
            JwtSecurityToken token = new JwtSecurityToken(AccessToken);
            return new AccessToken(AccessToken, token.ValidTo);
        }
    }
}

The above class converts bearer tokens to TokenCredentials. We will require TokenCredentials for authenticating DataLakeServiceclient and return the directory structure for the ADLS Gen2 storage.

For more information as to why this is required, please refer to the following article https://www.azureguru.net/customize-clientsecretcredential-class-for-onelake-authentication-in-microsoft-fabric

Program.cs >>

public static class Program {
  public static string TenantId = "Tenant Id";
  public static List < string > listoutput = new();

  private static async Task Main(string[] args) {
    WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

    builder.Services
      .AddAuthentication()
      .AddJwtBearer(
        options => {
          options.Authority = $ "https://login.microsoftonline.com/{TenantId}";
          options.Validate();
          options.TokenValidationParameters = new TokenValidationParameters {
            ValidIssuer = $ "https://sts.windows.net/{TenantId}/",
              ValidAudience = "https://storage.azure.com",
              ValidateIssuer = true,
              ValidateAudience = true
          };
        }
      );

    builder.Services.AddAuthorization(
      options => {
        options.AddPolicy(
          "AzureStorageAccess",
          policy => {
            policy.RequireAuthenticatedUser();
            policy.RequireClaim("aud", "https://storage.azure.com");
            policy.RequireClaim("iss", $ "https://sts.windows.net/{TenantId}/");
          }
        );
      }
    );

    builder.Services.AddOpenApi(
      options => {
        options.AddDocumentTransformer < BearerSecuritySchemeTransformer > ();
      }
    );

    WebApplication app = builder.Build();

    if (app.Environment.IsDevelopment()) {
      app.MapOpenApi();
    }

    app.MapScalarApiReference(
      options => {
        options.Title = "Scalar API";
        options.DarkMode = true;
        options.Favicon = "path";
        options.DefaultHttpClient = new KeyValuePair < ScalarTarget, ScalarClient > (
          ScalarTarget.CSharp,
          ScalarClient.RestSharp
        );
        options.HideModels = false;
        options.Layout = ScalarLayout.Modern;
        options.ShowSidebar = true;
        options.Authentication = new ScalarAuthenticationOptions {
          PreferredSecuritySchemes = new List < string > {
            "Bearer"
          }
        };
      }
    );

    app.UseHttpsRedirection();

    app.MapPost(
        "/login",
        () => {
          return Results.Ok(
            new {
              token = Security.Authentication.ReturnAuthenticationResult()
            }
          );
        }
      )
      .WithOpenApi();

    app.MapGet(
        "/accessazurestorage",
        async (ClaimsPrincipal claims, HttpContext context) => {
          var audience = claims.FindFirst("aud")?.Value;
          var issuer = claims.FindFirst("iss")?.Value;

          if (audience != "https://storage.azure.com") {
            throw new UnauthorizedAccessException("Unauthorized access !!!");
          }

          if (issuer != "$https://sts.windows.net/{TenantId}/") {
            throw new UnauthorizedAccessException("Unauthorized access !!!");
          }

          var token = context.Request.Headers["Authorization"]
            .ToString()
            .Replace("Bearer ", "");

          DataLakeServiceClient datalake_Service_Client;

          DataLakeFileSystemClient dataLake_FileSystem_Client;

          string dfsUri = $ "https://adlsfilestore.dfs.core.windows.net";

          TokenCredential tokenCredential = new AccessTokenCredential(token.ToString());

          datalake_Service_Client = new DataLakeServiceClient(
            new Uri(dfsUri),
            tokenCredential
          );

          dataLake_FileSystem_Client = datalake_Service_Client.GetFileSystemClient(
            "customers"
          );

          DataLakeDirectoryClient rootDirectory_ =
            dataLake_FileSystem_Client.GetDirectoryClient("");

          return Results.Ok(new {
            Output = TraverseDirectory(rootDirectory_)
          });
        }
      )
      .RequireAuthorization("AzureStorageAccess");

    app.MapGet("/fakeaccessazurestorage", () => {}).RequireAuthorization("AzureStorageAccess");

    app.Run();
  }

  public static async Task < List < string >> TraverseDirectory(
    DataLakeDirectoryClient directoryClient
  ) {
    await foreach(var item in directoryClient.GetPathsAsync()) {
      if (item.IsDirectory == true) {
        listoutput.Add(item.Name);
        string[] split = item.Name.Split("/");
        var subDir = directoryClient.GetSubDirectoryClient(
          split.Length == 1 ? split[0] : split[split.Length - 1]
        );
        await TraverseDirectory(subDir);
      }
    }

    return listoutput;
  }
}

Lets dissect the major aspects of the above code

Authentication >>

builder.Services.AddAuthentication().AddJwtBearer(options =>
{
    options.Authority = $"https://login.microsoftonline.com/{TenantId}";
    options.Validate();

    options.TokenValidationParameters = new TokenValidationParameters
    {
         ValidIssuer = "https://sts.windows.net/{TenantId}/",
         ValidAudience = "https://storage.azure.com",
         ValidateIssuer = true,
         ValidateAudience = true
    };
});

Authentication mechanism is used to validate the incoming tokens. It checks for two parameters, ValidIssuer and ValidAudience and ensures that the values for these parameters in the bearer token matches with

Authorization >>

 builder.Services.AddAuthorization(options =>
 {
     options.AddPolicy("AzureStorageAccess", policy =>
     {
         policy.RequireAuthenticatedUser();
         policy.RequireClaim("aud", "https://storage.azure.com");
         policy.RequireClaim("iss", $"https://sts.windows.net/{TenantId}/");
     });
 });

Create an authorization policy but before that ensure that request contains authenticated users and then check if the claims contain "aud" and "iss" and then validate its values.

Scalar UI >>

 builder.Services.AddOpenApi(
    options =>
    {
        options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
    }
);

WebApplication app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.MapScalarApiReference(
    options =>
    {
        options.Title = "Scalar API";
        options.DarkMode = true;
        options.Favicon = "path";
        options.DefaultHttpClient = new KeyValuePair<ScalarTarget, ScalarClient>(
            ScalarTarget.CSharp,
            ScalarClient.RestSharp
        );
        options.HideModels = false;
        options.Layout = ScalarLayout.Modern;
        options.ShowSidebar = true;
        options.Authentication = new ScalarAuthenticationOptions
        {
            PreferredSecuritySchemes = new List<string> { "Bearer" }
        };
    }
);

The above code customizes the Scalar UI to include the Bearer token section.

For more details refer to my article on the topic here.

Login >>

app.MapPost("/login", () =>
{
    return Results.Ok(new { token = Security.Authentication.ReturnAuthenticationResult() });
}).WithOpenApi();

Here we fetch the access token issued by Entra ID which is part of the OIDC flow.

TraverseDirectory >>

public static async Task<List<string>> TraverseDirectory(DataLakeDirectoryClient directoryClient)
{

    await foreach (var item in directoryClient.GetPathsAsync())
    {
        if (item.IsDirectory == true)
        {
            listoutput.Add(item.Name);
            string[] split = item.Name.Split("/");
            var subDir = directoryClient.GetSubDirectoryClient(split.Length == 1 ? split[0] : split[split.Length - 1]);
            await TraverseDirectory(subDir);
        }
    }

    return listoutput;
}

The above code recursively traverses the directory structure of a given Azure container . For more in-depth details on the approach you can refer to my article

https://www.azureguru.net/retrieve-the-hierarchical-directory-structure-from-azure-adls-gen2-storage

Accessazurestorage >>

app.MapGet(
    "/accessazurestorage",
    async (ClaimsPrincipal claims, HttpContext context) => {
      var audience = claims.FindFirst("aud")?.Value;
      var issuer = claims.FindFirst("iss")?.Value;

      if (audience != "https://storage.azure.com") {
        throw new UnauthorizedAccessException("Unauthorized access !!!");
      }

      if (issuer != $ "https://sts.windows.net/{TenantId}/") {
        throw new UnauthorizedAccessException("Unauthorized access !!!");
      }

      var token = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");

      DataLakeServiceClient datalake_Service_Client;

      DataLakeFileSystemClient dataLake_FileSystem_Client;

      string dfsUri = $ "https://adlsfilestore.dfs.core.windows.net";

      TokenCredential tokenCredential = new AccessTokenCredential(token.ToString());

      datalake_Service_Client = new DataLakeServiceClient(new Uri(dfsUri), tokenCredential);

      dataLake_FileSystem_Client = datalake_Service_Client.GetFileSystemClient("customers");

      DataLakeDirectoryClient rootDirectory_ = dataLake_FileSystem_Client.GetDirectoryClient(
        ""
      );
      return Results.Ok(new {
        Output = TraverseDirectory(rootDirectory_)
      });
    }
  )
  .RequireAuthorization("AzureStorageAccess");

The code is pretty straightforward. Check the audience and issuer values from claims and and fetch the access token from HttpContext .

Then convert the access token to TokenCredentials and use it to authenticate DataLakeServiceClient and then recursively traverse across the directory structure for the container customers. Note that it uses AzureStorageAccess policy (defined earlier in the login code) for Authorizing the request.

Executing the code the directory structure for the Azure container customers is displayed in the output

Fakeaccessazurestorage >>

  app.MapGet("/fakeaccessazurestorage", () =>
  {
  }).RequireAuthorization("AzureStorageAccess");

Accessing fakeaccessazurestorage endpoint without required audience and issuer details in the access token results in 401 Unauthorized exception.

Execution >>

Conclusion

In this article, we explored how to secure an ASP.NET Core Minimal API using Microsoft Entra ID and leverage delegated user authentication to access Azure Storage (ADLS GEN2) securely. By combining OpenID Connect, JWT Bearer authentication and Azure SDK with TokenCredential we were able to established a secure end-to-end request flow from user sign-in to resource access.

I hope this article helps you get started with implementing delegated authentication and authorization using Microsoft Entra ID in ASP.NET Core applications. While we used Azure Storage as the primary example in the article the concepts covered here extend to many Azure services that support Microsoft Entra ID authentication.

Thanks for reading !!!