Secure AI Functions in Microsoft Agent Framework with Microsoft Entra OAuth Role-Based Access Control

Search for a command to run...

No comments yet. Be the first to comment.
In these blogs, I have explored the intricacies and technical nuances of the underlying architecture, implementation details, authentication flows, and real-world integration scenarios.
After working on Semantic Kernel (SK) for sometime and then gradually shifting focus towards Microsoft Agent Framework (MAF), I’ve personally started to like MAF over SK. Implementation through SK had
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. Do

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

Lets take a scenario where you have an MAF (Microsoft Agent Framework) AIAgent that has tool calls to multiple AIFunctions.
You definitely wouldn't want an AIAgent to blindly invoke every available tool or function solely based on a user's prompt or simply because a user requested it. Instead there should be some solid guardrails in place to validate user permissions for invoking a given AIFunction. Before invoking a function, the agent should verify that the user has the required role or scope to perform the requested operation.
The conventional approach is to store user permissions in a database and retrieve them whenever a user logs in. While this works it would require maintaining a separate authorization store, keeping it synchronized with user identities on Entra and performing database lookups to determine user permissions.
But with Microsoft Entra ID , all the requisite roles and permissions are embedded directly into the access token as claims. Once the token has been validated the application can authorize tool calls based on these claims without querying an additional database.
This is more robust approach as the user details and permissions are already centralized in Entra and there is no need maintain custom permission tables or synchronization logic in a database.
Lets use a hypothetical scenario. An AIAgent invokes two AIFunctions
ReturnCountryCapital
ReturnCityTemperature
There are two users
sachin.nandanwar @ azureguru.net and sachin_nandanwar @ azureguru.net
Though I am using Entra users in this example, the same approach can be extended to Entra groups. So instead of assigning roles to individual users you can assign them to groups allowing all group members to inherit the same permissions assigned to the Entra group that they are a member of.
sachin.nandanwar @ azureguru.net should have permissions to invoke both the AIFunctions (ReturnCountryCapital and ReturnCityTemperature) while sachin_nandanwar @ azureguru.net permissions should be limited to invoke only ReturnCountryCapital
This article is heavily influenced by my article on Delegated Tokens and Application tokens wrt setting up role based access in Entra. You can refer to that article here.
But I would still cover the same steps from the above article in this article as well.
As mentioned earlier, there are two users
(Sachin.Nand) sachin.nandanwar @ azureguru.net
(Sachin Nandanwar) sachin_nandanwar @ azureguru.net
sachin.nandanwar @ azureguru.net will have full permissions to invoke both the AIFunctions (ReturnCountryCapital and ReturnCityTemperature) while sachin_nandanwar @ azureguru.net will have partial permissions limited to invoke only ReturnCountryCapital AIFunction
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.
The first step is to create a Service Principal. I created one called App Service Principal.
In the next step , click the Expose an API option. The format of the URI is api://{ApplicationId}.
ApplicationId above is the Service Principal ClientId
In the next step , define a scope by clicking the Add a scope option.
I defined a scope with name temperature_capital.read and used it across all other properties for the scope.
We will use this scope temperature_capital.read in our code to generate access token for the service principal on behalf of the signed in user.
In the next step, click App roles >> Create app role
and define two app roles
Temperature and Capital Read Role
Capital Read Role
The Temperature and Capital Read role is assigned to users who require access to invoke both the ReturnCountryCapital and ReturnCityTemperature functions while the Capital Read role is assigned to users who only need access to invoke the ReturnCountryCapital function. Users with this role are denied permission to invoke the ReturnCityTemperature function.
Now that we have app roles and API's defined, in the next step , under API permissions , click Add a permission and select and add the API 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.
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 App 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 the user/group to whom you want to grant Assignments.
I granted Temperature and Capital Read Role to the user Sachin Nand i.e. sachin.nandanwar @ azureguru.net
I then granted Capital Read Role to user sachin.nandanwar @ azureguru.net i.e. Sachin Nandanwar
User assigned to a role is listed under Users and groups
Now that the set up and the required prerequisite is in place , lets move on to the code.
Add the following references to your ASP.NET core project
dotnet add package Azure;
dotnet add package Azure.Core;
dotnet add package Microsoft.IdentityModel.Tokens;
dotnet add package Scalar.AspNetCore;
dotnet add package System.Security.Claims;
dotnet add package Microsoft.Agents.AI;
dotnet add package Azure.AI.OpenAI;
dotnet add package Microsoft.Extensions.AI;
dotnet add package System.Text.Json.Serialization;
Lets first set up the Scalar API interface.
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.
Next , 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 clientId = "Service Principal Client Id";
private static string[] scopes = { $"api://{clientId}/.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 as a variable. Ideally it should be placed in a config file or as an Environment variable
Scope used is : api://{Service Principal ClientId}/.default for the claims token.
Next, add the following code in Program.cs to read settings from the config file appsettings.json
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.Build();
Read credentials and register Chatclient and return a ChatClientAgent
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
var credential = new AzureKeyCredential(configuration["AppSettings:ApiKey"]);
servicecollection.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"));
});
Authentication >>
builder.Services.AddAuthentication().AddJwtBearer(options =>
{
options.Authority = $"https://login.microsoftonline.com/{TenantId}";
options.Validate();
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = $"https://sts.windows.net/{TenantId}/",
ValidAudience = $"api://{ClientId}",
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}/ and api://{ClientId}
options.MapInboundClaims = false . This setting is very important wrt validating the claims . Check the following screenshot

The claims and the scope format above is a standard JWT format. If options.MapInboundClaims = true (which the default) , the claim check through 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("CapitalTemperatureReadAccess", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("aud", $"api://{ClientId}");
policy.RequireClaim("iss", $"https://sts.windows.net/{TenantId}/");
policy.RequireClaim("scp", "temperature_capital.read");
});
});
Create an authorization policy but before that ensure that request contains authenticated users and then check if the claims contain "aud", "scp", "iss" and validate its values.
Next, define Request and Response objects for Temperature and Capital searches
CityTemperatureSearchRequest & CityTemperatureSearchResponse >>
public class CityTemperatureSearchRequest
{
public string City { get; set; }
}
public class CityTemperatureSearchResponse
{
public string City { get; set; }
public string Temperature { get; set; }
}
CountryCapitalSearchRequest & CountryCapitalSearchResponse >>
public class CountryCapitalSearchRequest
{
public string Country { get; set; }
}
public class CountryCapitalSearchResponse
{
public string Country { get; set; }
public string Capital { get; set; }
}
Next, create serialization metadata for both request and response types.
[JsonSerializable(typeof(CountryCapitalSearchRequest))]
[JsonSerializable(typeof(CountryCapitalSearchResponse))]
internal sealed partial class CountryCapitalSerializerContext : JsonSerializerContext;
[JsonSerializable(typeof(CityTemperatureSearchRequest))]
[JsonSerializable(typeof(CityTemperatureSearchResponse))]
internal sealed partial class CityTemperatureSerializerContext : JsonSerializerContext;
Define a delegate named ReturnCityTemperature that takes a search request CityTemperatureSearchRequest as input and returns a search response CityTemperatureSearchResponse.
public static Func<CityTemperatureSearchRequest, CityTemperatureSearchResponse> ReturnCityTemperature = (CityTemperatureSearchRequest) =>
{
switch (CityTemperatureSearchRequest.City)
{
case "Mumbai":
return new CityTemperatureSearchResponse
{
City = "Mumbai",
Temperature = "40"
};
break;
case "Pune":
return new CityTemperatureSearchResponse
{
City = "Pune",
Temperature = "41"
};
break;
case "Delhi":
return new CityTemperatureSearchResponse
{
City = "Delhi",
Temperature = "42"
};
break;
case "Chennai":
return new CityTemperatureSearchResponse
{
City = "Chennai",
Temperature = "43"
};
break;
}
return new CityTemperatureSearchResponse
{
City = CityTemperatureSearchRequest.City,
Temperature = "Unknown"
};
};
Similarly, define a delegate named ReturnCountryCapital that takes a search request CountryCapitalSearchRequest as input and returns a search response CountryCapitalSearchResponse.
public static Func<CountryCapitalSearchRequest, CountryCapitalSearchResponse> ReturnCountryCapital = (CountryCapitalSearchRequest) =>
{
switch (CountryCapitalSearchRequest.Country)
{
case "India":
return new CountryCapitalSearchResponse
{
Country = "India",
Capital = "New Delhi"
};
break;
case "USA":
return new CountryCapitalSearchResponse
{
Country = "USA",
Capital = "Washington"
};
break;
case "Germany":
return new CountryCapitalSearchResponse
{
Country = "Germany",
Capital = "Berlin"
};
break;
case "Russia":
return new CountryCapitalSearchResponse
{
Country = "Russia",
Capital = "Moscow"
};
break;
}
return new CountryCapitalSearchResponse
{
Country = CountryCapitalSearchRequest.Country,
Capital = "Unknown"
};
};
}
Register the above two delegates in the DI container as AIFunction with the corresponding SerializerOptions defined earlier.
builder.Services.AddSingleton<AIFunction>(sp =>
{
return AIFunctionFactory.Create(ReturnCountryCapital, new AIFunctionFactoryOptions { Name = "ReturnCountryCapital", Description = "Gets the capital city for a specific country.", SerializerOptions = CountryCapitalSerializerContext.Default.Options });
});
builder.Services.AddSingleton<AIFunction>(sp =>
{
return AIFunctionFactory.Create(ReturnCityTemperature, new AIFunctionFactoryOptions { Name = "ReturnCityTemperature", Description = "Gets the temperature of a city", SerializerOptions = CityTemperatureSerializerContext.Default.Options });
});
Login Endpoint >>
Here we fetch the access token issued by Entra ID which is part of the OIDC flow.
app.MapPost("/login", async() =>
{
tokens = await Security.Authentication.ReturnAuthenticationResult();
return Results.Ok(tokens);
}).WithOpenApi();
Chat Endpoint >>
app.MapGet("/chat", async (string request, ClaimsPrincipal claims) =>
{
var aud = claims.FindFirst("aud").Value;
var issuer = claims.FindFirst("iss").Value;
var roles = claims.FindFirst("roles").Value;
var scp = claims.FindFirst("scp").Value;
var chatclientlist = app.Services.GetRequiredKeyedService<IChatClient>("ChatClient");
var aifunctions = app.Services.GetServices<AIFunction>();
List<AITool> functions = new(aifunctions);
if (aud == $"api://{ClientId}" && issuer == $"https://sts.windows.net/{TenantId}/")
{
var options = new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
Tools = functions
}
};
if (roles == "TemperatureCapital.Read" && scp == "temperature_capital.read")
{
options.ChatOptions.Instructions = "You return capital city of a country and you also provide temperature of a city.You will use only the data provided to you and not use any external data";
}
if (roles == "Capital.Read" && scp == "temperature_capital.read")
{
options.ChatOptions.Instructions = "You return ONLY the capital city of a country and if user asks for temperature you respond 'You are not are authorized to access temperature data'. You will use only the data provided to you and not use any external data";
}
var agent = chatclientlist.AsAIAgent(options);
AgentResponse response = await agent.RunAsync(request);
return Results.Ok(response.Text);
}
return Results.Unauthorized();
}).RequireAuthorization("CapitalTemperatureReadAccess");
Let's break down the above code step by step.
Fetch audience, iss(issuer), scp(scopes) and roles values from claims.
var aud = claims.FindFirst("aud").Value;
var issuer = claims.FindFirst("iss").Value;
var roles = claims.FindFirst("roles").Value;
var scp = claims.FindFirst("scp").Value;
Retrieve IChatClient keyed service and AIFunction from the service collection and set it to the functions list variable of type List
var chatclientlist = app.Services.GetRequiredKeyedService<IChatClient>("ChatClient");
var aifunctions = app.Services.GetServices<AIFunction>();
List<AITool> functions = new(aifunctions);
Validate issuer and audience and if validated create ChatClientAgentOptions and set the Tools to the above functions list.
if (aud == $"api://{ClientId}" && issuer == $"https://sts.windows.net/{TenantId}/")
{
var options = new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
Tools = functions
}
};
if (roles == "TemperatureCapital.Read" && scp == "temperature_capital.read")
{
options.ChatOptions.Instructions = "You return capital city of a country and you also provide temperature of a city.You will use only the data provided to you and not use any external data";
}
if (roles == "Capital.Read" && scp == "temperature_capital.read")
{
options.ChatOptions.Instructions = "You return ONLY the capital city of a country and if user asks for temperature you respond 'You are not are authorized to access temperature data'. You will use only the data provided to you and not use any external data";
}
var agent = chatclientlist.AsAIAgent(options);
AgentResponse response = await agent.RunAsync(request);
return Results.Ok(response.Text);
}
return Results.Unauthorized();
Then validate roles and scopes values . If roles is TemperatureCapital.Read then set ChatOptions instructions to return both capital of the country and temperature of the city and if the roles is Capital.Read then set ChatOptions instructions to return only the capital and deny any other requests with a custom message 'You are not are authorized to access temperature data'.
Assign ChatOptions to agent derived from IChatClient and send the prompt to the agent and return the response.
And if the validations fail, return an Unauthorized error.
Login as : sachin.nandanwar @ azureguru.net and send the following prompt
What is capital of India and whats the temperature in Mumbai ?
Login as : sachin.nandanwar @ azureguru.net and send the the same prompt
and the access to the temperature data is denied.
Instead of building a separate security layer for your AIAgents to handle permissions for AIFunction invocation , leveraging user claims through Entra issued OAuth tokens can be more robust ,clean and maintainable architecture.
With this architecture you get a centralized control over permissions required for user driven agent function invocation which ensures that users can invoke only the functions they are permitted to access.
Thanks for reading !!!