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

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.

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/50632edc-6013-4f1f-9e39-70acb48b173d.png align="center")

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

*   *Leverage MSAL for Microsoft Fabric* >> [https://www.azureguru.net/msal-for-microsoft-fabric](https://www.azureguru.net/msal-for-microsoft-fabric)
    
*   *Retrieve ADLS Gen 2 directory structure by DataLakeServiceClient >>* [https://www.azureguru.net/retrieve-the-hierarchical-directory-structure-from-azure-adls-gen2-storage](https://www.azureguru.net/retrieve-the-hierarchical-directory-structure-from-azure-adls-gen2-storage)
    
*   *Leverage Service Principal to fetch Microsoft Fabric Directory structure* >> [https://www.azureguru.net/service-principal-in-microsoft-fabric](https://www.azureguru.net/service-principal-in-microsoft-fabric)
    
*   *Creating a TokenCredential from a Bearer Token >>* [https://www.azureguru.net/customize-clientsecretcredential-class-for-onelake-authentication-in-microsoft-fabric](https://www.azureguru.net/customize-clientsecretcredential-class-for-onelake-authentication-in-microsoft-fabric)
    
*   *Customizing Scalar UI for .NET API's >>* [https://www.azureguru.net/customize-scalar-UI-for-net-api](https://www.azureguru.net/customize-scalar-UI-for-net-api)
    
*   *JWT tokens in Minimal API's >>* [https://www.azureguru.net/implementing-jwt-tokens-in-minimal-api-net-core](https://www.azureguru.net/implementing-jwt-tokens-in-minimal-api-net-core)
    

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

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/3e908e16-6154-4dda-9f3d-d4412f4d5b08.png align="center")

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

```html
https://storage.azure.com/
```

### **SetUp**

The ADLS storage has the following structure

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/5fcd2c53-8803-4fa4-8c5c-c7aed9d179d3.png align="center")

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**
    
*   **sachin\_nandanwar@azureguru.net**
    

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

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/fedd98ba-a400-48c9-9bb5-0d4c96d0f135.png align="center")

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 .

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/770ff9a7-8d13-42ef-a576-cdff6643a639.png align="center")

**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.**

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/03ba6eeb-e87f-4d86-8bff-ccec087d2ad5.png align="center")

> 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](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](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

```csharp
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](https://www.azureguru.net/implementing-jwt-tokens-in-minimal-api-net-core), 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 >>**

```csharp
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 >>**

```csharp
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](https://www.azureguru.net/customize-clientsecretcredential-class-for-onelake-authentication-in-microsoft-fabric)

**Program.cs >>**

```csharp
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*** *\>>*

```csharp
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

*   [https://sts.windows.net/{TenantId}/](https://sts.windows.net/%7BTenantId%7D/) and https://storage.azure.com
    

***Authorization*** *\>>*

```csharp
 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.cs >>***

```csharp
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

internal sealed class BearerSecuritySchemeTransformer : IOpenApiDocumentTransformer
{
    private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;

    public BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider)
    {
        _authenticationSchemeProvider = authenticationSchemeProvider;
    }

    public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
    {
        var authenticationSchemes = await _authenticationSchemeProvider.GetAllSchemesAsync();

        if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer"))
        {
            document.Components ??= new OpenApiComponents();
            document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
            document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
            {
                Type = SecuritySchemeType.Http,
                Scheme = "bearer",
                In = ParameterLocation.Header,
                BearerFormat = "JWT"
            };

            foreach (var operation in document.Paths.Values.SelectMany(path => path.Operations))
            {
                if (operation.Value.Security == null)
                {
                    operation.Value.Security = new List<OpenApiSecurityRequirement>();
                }
                var securityRequirement = new OpenApiSecurityRequirement
                {
                    [new OpenApiSecuritySchemeReference("Bearer", document)] = []
                };

                operation.Value.Security ??= new List<OpenApiSecurityRequirement>();
                operation.Value.Security.Add(securityRequirement);
            }
        }
    }
}
```

***Scalar UI >>***

```csharp
 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.

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/db438985-06ab-49f4-a580-d841f41e0ef3.png align="center")

For more details refer to my article on the topic [here](https://www.azureguru.net/customize-scalar-UI-for-net-api).

***Login >>***

```csharp
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 >>***

```csharp
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](https://www.azureguru.net/retrieve-the-hierarchical-directory-structure-from-azure-adls-gen2-storage)

***Accessazurestorage >>***

```csharp
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

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/1aec9a73-9d48-4ba7-bf3e-264f1e6eb52f.png align="center")

**Fakeaccessazurestorage >>**

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

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

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/da2ccd8d-b6ad-4b53-9141-c6d01fff4133.png align="center")

### Execution >>

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/968382d3-d2de-4b20-874d-8733ed5ded02.gif align="center")

### 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 !!!
