# Build AI Agents with Microsoft Agent Framework to Access Azure Services Using Entra OAuth

Imagine a scenario where you have an AI agent that needs to access Azure services, such as Azure Storage. Simply granting the agent open and unrestricted access to Azure resources is not an option. Doing so is a significant security risk, allowing the agent to perform operations beyond what the end user is authorized to do.

In this example I am going to demonstrate how to leverage OpenID Connect (OIDC) to enable an AI agent to securely access Azure Storage through an ASP.NET Core Minimal API by validating the underlying OIDC claims and also enforcing permissions.

**But why minimal API's ?**

Honestly, I couldn't figure out a straightforward approach to leverage **AG-UI** protocol to pass OAuth 2.0 access token back to the server.

If you would like to know more about AG-UI protocol in Microsoft Agent Framework , then you can refer to my article on the topic [here](https://www.azureguru.net/ag-ui-protocol-in-microsoft-agent-framework).

In this article, I will use Scalar UI as the API interface which I have done in most of my previous articles on Minimal API's. The article [here](https://www.azureguru.net/securing-azure-storage-in-asp-net-core-minimal-api-s-with-microsoft-entra-id-and-openid-connect-oidc) is a deep dive on how to leverage it.

So the aim is to access list of directories in an Azure storage container based on the user identity and prompted through Microsoft Agent Framework (MAF) AI Agent provided the signed-in user user has the required RBAC role access to the Azure storage. The agent uses OAuth 2.0 access tokens issued on behalf of the signed-in user. The Azure storage is an ADLS GEN2 storage.

### Flow

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/d18a00fd-4cf8-490b-9903-37853dbe4c26.png align="center")

### SetUp

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

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

The ADLS GEN2 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 container prompted through the AI Agent. 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**](mailto:sachin_nandanwar@azureguru.net)
    

[**sachin.nandanwar@azureguru.net**](mailto: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**](mailto:sachin_nandanwar@azureguru.net) is not assigned any RBAC role.

Create a Service Principal and grant **Azure Storage** delegated permissions.

I created one named **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 Azure.AI.OpenAI;
dotnet add package Azure.Storage.Files.DataLake;
dotnet add package Microsoft.IdentityModel.Tokens;
dotnet add package Scalar.AspNetCore;
dotnet add package System.Security.Claims;
dotnet add Microsoft.Extensions.AI;
dotnet add Microsoft.Extensions.Configuration;
dotnet add Microsoft.Extensions.DependencyInjection;
dotnet add Microsoft.Extensions.Hosting;
```

First, we define an **Authentication** class that validates the user's sign-in and requested scopes with Microsoft Entra ID and then returns a Entra 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)

Add **appsetting.json** to the project

```yaml
"AppSettings": { 
    "Chat_DeploymentName": "Deployment Name",
    "EndPoint": "Azure OpenAI endpoint",
    "ApiKey": "Azure OpenAI API key"
}
```

In **launchSettings.json**, configure the ports on which the server should listen.

```yaml
{
    "$schema": "https://json.schemastore.org/launchsettings.json",
    "profiles": {       
        "https": {
            "commandName": "Project",
            "dotnetRunMessages": true,
            "launchBrowser": false,
            "applicationUrl": "https://localhost:7129;http://localhost:5176",
            "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            }
        }
    }
}
```

In the above settings , the application is configured to listen on ports **7129** (HTTPS) and **5176** (HTTP). For this article, we will use **7129** on https.

### **Code**

Now that we have all the underlying artifacts in place, add the following code to read the settings from **appsettings.json** in **Program.cs**

```csharp
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.Build();
```

**Create a application builder**

```csharp
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
```

**Create a IChatClient DI container**

```csharp
builder.Services.AddHttpClient().AddLogging();

var credential = new AzureKeyCredential(configuration["AppSettings:ApiKey"]);

builder.Services.AddKeyedChatClient(
    "ChatClient",
    (
        sp =>
            new AzureOpenAIClient(new Uri(configuration["AppSettings:EndPoint"]), credential)
                .GetChatClient(configuration["AppSettings:Chat_DeploymentName"])
                .AsIChatClient()
    )
);

builder.Services.AddSingleton<ChatClientAgent>(
    sp =>
    {
        return new ChatClientAgent(sp.GetKeyedService<IChatClient>("ChatClient"));
    }
);
```

**AIFunction**

Register an **AIFunction** with name **ReturnDirectories** in the DI container. I am using **FunctionInvokingChatClient** to pass the access token as the parameter to the AIFunction.

```csharp
builder.Services.AddSingleton<AIFunction>(
    sp =>
    {
        return AIFunctionFactory.Create(
            async (string ContainerName) =>
                await ReturnContainerDirectories(
                    ContainerName,
                    FunctionInvokingChatClient.CurrentContext.Options.AdditionalProperties[
                        "AccessToken"
                    ].ToString()
                ),
            new AIFunctionFactoryOptions
            {
                Name = "ReturnDirectories",
                Description = "Returns a list of directories"
            }
        );
    }
);
```

For more details on FunctionInvokingChatClient you can refer to my article [here](https://www.azureguru.net/functioninvokingchatclient-for-tool-calling-in-microsoft-agent-framework).

**Authentication**

Authentication mechanism validates the incoming tokens. In this example 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**](https://storage.azure.com)
    

and if the claim does not match it rejects the token.

```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
    };
});
```

**Authorization**

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.

```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}/");
     });
 });
```

**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")

Register the **OpenAPI** service in the DI container add a Scalar document transformer.

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

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

**ReturnContainerDirectories Function**

This function contains arguments container name and token.The token value is passed to the **TokenCredential** object of the **DatalakeServiceClient** which then eventually returns the directory structure for the Azure container through the **TraverseDirectory** function.

```csharp
public static Func<string, string, Task<List<string>>> ReturnContainerDirectories = async (
    ContainerName,
    token
) =>
{
    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(ContainerName);
    DataLakeDirectoryClient rootDirectory_ = dataLake_FileSystem_Client.GetDirectoryClient("");
    listoutput.Clear();

    return await TraverseDirectory(rootDirectory_);
};
```

**TraverseDirectory**

The function 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)

```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;
}
```

**Login Endpoint**

The access token issued by Entra ID which is part of the OIDC flow and is passed to this endpoint.

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

**Chat Endpoint**

The **chat** endpoint is the most crucial piece of code.

```csharp
app.MapGet("/chat", async (string request, HttpContext httpcontext, ClaimsPrincipal claims) =>
 {
     var aud = claims.FindFirst("aud").Value;
     var issuer = claims.FindFirst("iss").Value;
     var chatclientlist = app.Services.GetRequiredKeyedService<IChatClient>("ChatClient");

     var aifunctions = app.Services.GetServices<AIFunction>();

     List<AITool> functions = new(aifunctions);

     if (aud == "https://storage.azure.com" && issuer == $"https://sts.windows.net/{TenantId}/")
     {
         var agent = chatclientlist.AsAIAgent(new ChatClientAgentOptions
         {
             ChatOptions = new ChatOptions
             {
                 Tools = functions,
                 Instructions = "You return list of Azure directories for the given container",
                 AdditionalProperties = new AdditionalPropertiesDictionary {["AccessToken"] = httpcontext.Request.Headers["Authorization"].ToString().Replace("Bearer ", "") }
             }
         }
            );
         AgentResponse response = await agent.RunAsync(request);              

         return Results.Ok(response.Text);
     }

     return Results.Unauthorized();

 }).RequireAuthorization("AzureStorageAccess");

app.Run();
```

Let's break down the code step by step.

First, retrieve the audience and issuer values from JWT claims.

```csharp
var aud = claims.FindFirst("aud").Value;
var issuer = claims.FindFirst("iss").Value;
```

Then, retrieve a service of type **IChatClient** from the DI container that was registered earlier.

```csharp
var chatclientlist = app.Services.GetRequiredKeyedService<IChatClient>("ChatClient");
```

Get a list of all **AIFunction** from the DI container. In our case we only have one AIFunction i.e **ReturnDirectories**.

```csharp
var aifunctions = app.Services.GetServices<AIFunction>();
List<AITool> functions = new(aifunctions);
```

Validate the **aud** and **issuer(iss)** values. Then create an **AIAgent** from the **IChatClient** instance **chatclientlist** declared earlier.

Next, configure **ChatOptions** with **Tools** and set **AdditionalProperties** AccessToken that is derived from **HttpContext**. AdditionalProperties values are accessed through **FunctionInvokingChatClient** in the **AIFunction**.

Pass the request to the agent and return the response.

```csharp
if (aud == "https://storage.azure.com" && issuer == $"https://sts.windows.net/{TenantId}/")
{
    var agent = chatclientlist.AsAIAgent(
        new ChatClientAgentOptions
        {
            ChatOptions = new ChatOptions
            {
                Tools = functions,
                Instructions = "You return list of Azure directories for the given container",
                AdditionalProperties = new AdditionalPropertiesDictionary
                {
                    ["AccessToken"] = httpcontext.Request.Headers["Authorization"]
                        .ToString()
                        .Replace("Bearer ", "")
                }
            }
        }
    );
    AgentResponse response = await agent.RunAsync(request);

    return Results.Ok(response.Text);
}
```

I first logged in as [sachin.nandanwar@azureguru.net](mailto:sachin.nandanwar@azureguru.net) and passed the following prompt to the agent.

```plaintext
Give me list of directories from the container customers.
```

the agent returns the list of all the directories in the **customers** container.

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/87668d2e-8d3f-4ba1-93a6-36ab876c8431.png align="left")

But when I logged in as [sachin\_nandanwar@azureguru.net](mailto:sachin_nandanwar@azureguru.net) as expected, the access to the directories was restricted.

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/7742afad-1919-480b-b27f-aaa114fca80d.png align="center")

### Execution

![](https://cdn.hashnode.com/uploads/covers/6693c62c166ee9c594cffda0/d62e74f6-0f46-459a-96d3-b5f34cbacb29.gif align="center")

### Conclusion

By combining the Microsoft Agent Framework with Microsoft Entra ID, you can build AI agents that securely access Azure services.

The agent uses OAuth 2.0 access tokens issued on behalf of the signed-in user that ensures that every operation is performed within the user's identity and the user RBAC permissions.

Thanks for reading !!!
