Skip to main content

Command Palette

Search for a command to run...

Understanding Delegated Tokens vs Application Tokens through Claims-Based Authorization in Microsoft Entra ID

Updated
14 min readView as Markdown
Understanding Delegated Tokens vs Application Tokens through Claims-Based Authorization in Microsoft Entra ID
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.

My earlier article on securing Azure Storage in ASP.NET core Minimal API's extensively leveraged RBAC access control and through Microsoft Entra ID for user sign-in through OpenID Connect (OIDC) to authenticate and authorized users.

But sometimes there can be scenarios where maintaining RBAC access cannot be feasible . Imagine managing an Azure tenant in an organization that has 1,000+ employees and hundreds of storage accounts and Azure services and where every user requires different access to these services. Though manageable through RBAC but managing and maintaining such a large number of roles can be challenging and cause operational overhead.

An alternative solution can be a claim based authorization through Microsoft Entra ID issued access tokens along with authorization policies.

Instead of assigning Azure RBAC roles to every user for every resource, Microsoft Entra ID can issue access tokens containing claims that describe the user's permissions. The application can then evaluate these claims through authorization policies and determine if the user is allowed to perform a particular operation.

In this article, we will build an ASP.NET Core Minimal API that authenticates users with Microsoft Entra ID. Once the claims are validated from the access token, the API 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, based on access granted to him through App roles in Microsoft Entra.

A sample claim based access token might contain application-specific claims similar to the following:

{
  "sub": "1234567890",
  "name": "ABC",
   "roles": [
        "Storage.Access"
      ],
  "permissions": [
    "storage.read",
    "storage.write"
  ]
}

In the above example, the permissions claim is interpreted by the application to determine whether the user is authorized to perform read or write operations on the storage based on the roles .

Instead of granting RBAC role access to individual user, only the service principal is granted RBAC role on the Azure service, in our case the access is granted on Azure ADLS Gen2 storage to the service principal.

The application authenticates on the Azure Storage using a service principal (or managed identity) that already has the necessary Azure RBAC permissions.

In such a scenario we require two access tokens. The first token will contain the user claims and the second token will have the necessary scope permissions to perform the underlying actions on the Azure service (in this case Azure ADLS Gen2 storage) on behalf of the user.

The application first validates the claims in the user access token and once its validated it uses the service principal token to authenticate the Azure storage based on the audience value of the delegated scopes assigned to it in Microsoft Entra ID.

The initial steps to be performed are similar to the article below i.e. exposing a service principal API ,defining custom scopes and finally granting permissions to the exposed API.

https://www.azureguru.net/azure-services-authentication-through-microsoft-managed-identity

Flow

To add more context to the above flow, the scopes used will be

api://{ApplicationId} and https://storage.azure.com

created through the Expose API option and Delegated Permissions for the service principal.

SetUp

There are two users :

Only (Sachin.Nand) sachin.nandanwar@azureguru.net will be assigned the necessary App Role accesses for the 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 below 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.

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

Grant Storage Blob Data Owner role access to the service principal for the storage service.

In the next step ,under the Expose an API option, click the Add button. By default the Application ID URI is the ClientID of the Service Principal.

The format is api://{ApplicationId}.

Cross check the Application ID URI with the Client ID of the service principal.

In the next step define a new scope.

Click “Add a scope” option and enter storage_read as the scope name.

Once added, the scope should be visible on the Expose an API page.

In the next step , under API permissions , click Add a permission and select the API that was created in the previous step.

This might sound counterintuitive to see that we have to assign permissions for an API to the service principal that created that API in the first place. But that’s the way it is. Not doing so results in an Unauthorized access error.

Please ensure that Azure Storage Delegated permission is also assigned to service principal.

In the next step, create an App Role.

App roles (Application Roles) is a feature of Role-Based Access Control (RBAC) that defines what actions a user, group or service is allowed to perform within a specific application at the apps identity level.

App role name is AzureStorage and value is AzureStorage.Read

The role AzureStorage created will be part of the access token. This is very crucial aspect to validate user permissions.

The next step is to enable Assignment required property.

Browse to Entra ID > Enterprise apps >> All applications

Select your application. In our case it is ADLS Gen2 Service Principal

Ensure that Assignment required? is set to Yes . By default it is No.

Navigate to Users and groups in the same page add click Add user/group

Click None Selected and Search for user to whom you want to grant the Assignements.

Grant assignment to the selected user . In this case I will assign it only to the user Sachin Nand

Now that the set up and the required prerequisite is in place , lets move on to the code.

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;

We first 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 TenantId = "Tenant Id";
    public static string ClientId = "Client Id of the Service Principal";
    public static string ClientSecret = "Client secret of the Service Principal"   

    private static IEnumerable < string > scopes = new List < string > {
      "https://storage.azure.com/.default",
      "api://{ApplicationID}/.default"
    };

    private static string Authority = $"https://login.microsoftonline.com/{TenantId}";
    private static string RedirectURI = "http://localhost";

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

      var accounts_claims = await PublicClientApplication_claims.GetAccountsAsync();
      AuthenticationResult claims_result, storage_result;
      var scopeList = scopes.ToList();
      try {

        claims_result = await PublicClientApplication_claims.AcquireTokenSilent(new [] {
            scopeList.ElementAt(0)
          }, accounts_claims.First())
          .ExecuteAsync()
          .ConfigureAwait(false);

      } catch {
        claims_result = await PublicClientApplication_claims.AcquireTokenInteractive(new [] {
            scopeList.ElementAt(0)
          })
          .ExecuteAsync()
          .ConfigureAwait(false);

      }

      JwtSecurityToken claims_token = new JwtSecurityToken(claims_result.AccessToken);

      var app = ConfidentialClientApplicationBuilder
        .Create(ClientId)
        .WithClientSecret(ClientSecret)
        .WithAuthority(Authority)
        .Build();

      storage_result = await app
        .AcquireTokenForClient(new [] {
          scopeList.ElementAt(1)
        })
        .ExecuteAsync();

      JwtSecurityToken storage_token = new JwtSecurityToken(storage_result.AccessToken);

      return [claims_token, storage_token];

    }
  }
}

For brevity I have defined ClientId, ClientSecret and TenantId in variables. Ideally they should be placed in a config file and ClientSecret stored to a secured location like Environment variables or Key vault and their values fetched from there.

Few important aspects of the above code.

ReturnAuthenticationResult function returns two tokens (storage token and claim token) through array JwtSecurityToken[] .

 return [claims_token, storage_token];

The scopes used are : https://storage.azure.com/.default for the storage token and api://{ApplicationID}/.default for the claims token.

claims_token is a delegated user token (idtyp = user) and storage_token is a service principal token (idtyp = app)

Why two different token types (dtyp = user and idtyp = app) is required ?

Its because the user no longer has any RBAC role access to the storage but the service principal has (we had granted that earlier).So only service principal token will be authorized for any access to the storage.

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


using AccesTokenCredentials;
using Azure.Core;
using Azure.Storage.Files.DataLake;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

public static class Program
{
     public static string TenantId = "Tenant Id";
     public static string ClientId = "Client Id of the Service Principal";
    public static List<string> listoutput = new();
    public static JwtSecurityToken[] tokens;
    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.MapInboundClaims = false;
            options.Validate();
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidIssuer = $"https://sts.windows.net/{TenantId}/",
                ValidAudience = $"api://{ClientId}",
                ValidateIssuer = true,
                ValidateAudience = true
            };
        });

        builder.Services.AddAuthorization(options =>
        {
            options.AddPolicy("AzureStorageAccess", policy =>
            {
                policy.RequireAuthenticatedUser();
                policy.RequireClaim("aud", $"api://{ClientId}");
                policy.RequireClaim("iss", $"https://sts.windows.net/{TenantId}/");
                policy.RequireClaim("scp", "storage.read");
                policy.RequireClaim("roles", "AzureStorage.Read");

            });

        });

        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", async () =>
         {     
             tokens = await Security.Authentication.ReturnAuthenticationResult();
             return Results.Ok(tokens);
         });


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

            if (audience != $"api://{ClientId}")
            {
                throw new UnauthorizedAccessException("Unauthorized access !!!");
            }

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


            if (scp != "storage.read")
            {
                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(tokens[0].RawData.ToString());
            datalake_Service_Client = new DataLakeServiceClient(new Uri(dfsUri), tokenCredential);
            dataLake_FileSystem_Client = datalake_Service_Client.GetFileSystemClient("customers");
            DataLakeDirectoryClient rootDirectory_ = dataLake_FileSystem_Client.GetDirectoryClient("");
            listoutput.Clear();
            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

We declare an array of type JwtSecurityToken[] which is used to store the values for claims_token and storage_token returned by the Authentication class defined earlier.

 public static JwtSecurityToken[] tokens;

Authentication >>

builder.Services.AddAuthentication().AddJwtBearer(options =>
{
    options.Authority = $"https://login.microsoftonline.com/{TenantId}";
    options.MapInboundClaims = false;
    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

The claims and the scope format above is a standard JWT format . If options.MapInboundClaims = true (which the default) , the claim check through the HttpContext will fail . For example something like

var roles = claims.FindFirst("roles")?.Value

roles will always be null as it checks for the keyword "roles"

But setting options.MapInboundClaims = false the format changes

Authorization >>

   builder.Services.AddAuthorization(options =>
  {
      options.AddPolicy("AzureStorageAccess", policy =>
      {
          policy.RequireAuthenticatedUser();
          policy.RequireClaim("aud", $"api://{ClientId}");
          policy.RequireClaim("iss", $"https://sts.windows.net/{TenantId}/");
          policy.RequireClaim("scp", "storage.read");
          policy.RequireClaim("roles", "AzureStorage.Read");
      });
  });

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

Scalar.cs >>

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

 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", async() =>
{
 tokens = await Security.Authentication.ReturnAuthenticationResult();
return Results.Ok(tokens);
}).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;
        var scp = claims.FindFirst("scp")?.Value;
        var roles = claims.FindFirst("roles")?.Value;

        if (audience != $ "api://{ClientId}") {
          throw new UnauthorizedAccessException("Unauthorized access !!!");
        }

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

        if (scp != "storage.read") {
          throw new UnauthorizedAccessException("Unauthorized access !!!");
        }

        if (roles != "AzureStorage.Read") {
          throw new UnauthorizedAccessException("Unauthorized access !!!");
        }

        DataLakeServiceClient datalake_Service_Client;
        DataLakeFileSystemClient dataLake_FileSystem_Client;
        string dfsUri = $ "https://adlsfilestore.dfs.core.windows.net";

        TokenCredential tokenCredential = new AccessTokenCredential(tokens[0].RawData.ToString());

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

        dataLake_FileSystem_Client = datalake_Service_Client.GetFileSystemClient("customers");

        DataLakeDirectoryClient rootDirectory_ = dataLake_FileSystem_Client.GetDirectoryClient("");

        listoutput.Clear();

        return Results.Ok(new {
          Output = TraverseDirectory(rootDirectory_)

        });
      })
      .RequireAuthorization("AzureStorageAccess");

    app.Run();
    }

The code is pretty straightforward. Validate audience, issuer, scp and roles values from claims.

If all the above criteria are met , then fetch the storage_token from the tokens array of type JwtSecurityToken[] (declare earlier) and pass the value to TokenCredential for DatalakeServiceClient which then eventually returns the directory structure for the Azure container customers.

Login in with sachin.nandanwar@azureguru.net

Login in with sachin_nandanwar@azureguru.net

Execution >>

Conclusion >>

The two-token pattern keeps user authorization and resource authorization separate. Leveraging Claims-based authorization and Azure RBAC together results in a much cleaner security model. User are authorized to access the application through their claims while the service principal accesses Azure resources using its own identity. This removes the need to grant Azure RBAC permissions to every user across every Azure resource to which user needs access.

I hope this article helps you get started with claims-based authorization and the two-token pattern in Microsoft Entra ID.

Thanks for reading !!!