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

Search for a command to run...

No comments yet. Be the first to comment.
The tittle sounds confusing right ? So how to access Azure services with federated credentials that are tied with User Managed Identities ? Even I was skeptical if it is even possible because its more

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 au

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

As more organizations adopt AI-driven setups and applications the need for governance and operational security across these services has been more than important than ever before. You definitely would

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.
To add more context to the above flow, the scope used is
https://storage.azure.com/
The ADLS GEN2 storage has the following structure
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 is assigned the necessary RBAC role accesses to the storage. Typically it has to be Storage Blob Data Owner role.
You could also grant Storage Blob Data Contributor role but the Storage Blob Data Owner role has POSIX access control (ACL access) that auto grants all the (r-w-x) privileges to the owner for all the underlying objects under the container.
For instance in the following screenshot we can see that the owner i.e. the Storage Blob Data Owner was auto assigned the (r-w-x) access .
sachin_nandanwar@azureguru.net is not assigned any RBAC role.
Create a Service Principal and grant Azure Storage delegated permissions.
I created one named ADLS GEN2 Service Principal.
Ensure that you the Scalar set up is configured in your ASP.NET core project. For more details refer to my following article
https://www.azureguru.net/customize-scalar-UI-for-net-api
Also ensure that you have a thorough understanding of Minimal API's and implementation of ClaimPrincipal, Authentication and Authorization for Minimal API's. Please refer to my article to have a better understanding of the topic.
https://www.azureguru.net/implementing-jwt-tokens-in-minimal-api-net-core
You can ignore the custom JWToken aspect from the above article as in that article the focus was on creating custom JWTokens.
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.
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.
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.
But when I logged in as sachin_nandanwar@azureguru.net as expected, the access to the directories was restricted.
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 !!!