Skip to main content

Command Palette

Search for a command to run...

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

Updated
11 min readView as Markdown
Build AI Agents with Microsoft Agent Framework to Access Azure Services Using Entra OAuth
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.

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.

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

Microsoft Agent Framework and Azure services

SetUp

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

https://storage.azure.com/

The ADLS GEN2 storage has the following structure

Microsoft Agent Framework and Azure services

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

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

Microsoft Agent Framework and Azure services

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 .

Microsoft Agent Framework and Azure services

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.

Microsoft Agent Framework and Azure services

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

Add appsetting.json to the project

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

{
    "$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

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

Create a application builder

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

Create a IChatClient DI container

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.

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.

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

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

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.

 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

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.

Scalar UI

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

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

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

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.

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

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.

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

Chat Endpoint

The chat endpoint is the most crucial piece of code.

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.

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.

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.

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.

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 and passed the following prompt to the agent.

Give me list of directories from the container customers.

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

Microsoft Agent Framework and Azure services

But when I logged in as sachin_nandanwar @ azureguru.net as expected, the access to the directories was restricted.

Microsoft Agent Framework and Azure services

Execution

Microsoft Agent Framework and Azure services

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

More from this blog

My Ramblings On Microsoft Data Stack

116 posts

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.