Roles from your sign-in
Make the roles of your sign-in reach RequiredRoles: JWT bearer tokens, Microsoft Entra ID app roles and groups, Windows groups, and roles from your database.
On this page
Use this page when your users sign in with JSON Web Tokens (JWT), Microsoft Entra ID or Windows, and an action with RequiredRoles is refused for a user who holds the role. MentorAgent does not sign users in and keeps no list of roles: it asks the signed-in user, as ASP.NET Core builds it. The roles therefore have to be on that user, in the claim that the user's identity names as its role claim. This page shows how to get them there for each kind of sign-in.
How MentorAgent checks a role
Section titled “How MentorAgent checks a role”Every example on this page uses this action:
using Novior.MentorAgent.Attributes;
public sealed class CaseApprovals
{
public List<string> Approved { get; } = [];
// Offered only to users in the Supervisors role; it asks for confirmation
[MentorAction(Description = "Approves the refund of a support case",
RequiredRoles = ["Supervisors"], RequiresConfirmation = true)]
public string ApproveRefund(string caseId)
{
Approved.Add(caseId);
return $"Refund approved for {caseId}";
}
}- The check is
ClaimsPrincipal.IsInRole. Any one of the roles inRequiredRolesis enough. The user must be signed in: for an anonymous user, the action is refused. IsInRolereads the claim that each identity names as its role claim, itsRoleClaimType. By default that isClaimTypes.Role. Arolesclaim counts only when the identity namesrolesas its role claim.- Role names in claims are case-sensitive.
supervisorsdoes not matchSupervisors. - A user without the role is never offered the action, and a call made anyway is refused before any confirmation box: see Two checks, in this order.
- The model is told the user's roles: the values of the role claims of every identity of the user. Windows groups are left out, because they are security identifiers that name nothing the model can use.
Where MentorAgent finds the user depends on the channel:
| Where the request comes from | The user is |
|---|---|
The SignalR hub, /mentor/chat and Agent-to-Agent (A2A) | The user of the request, HttpContext.User, after ASP.NET Core authentication |
| A Blazor Server circuit | The user that the AuthenticationStateProvider reports |
The /mcp endpoint | The user that McpCallerPrincipal returns |
So everything on this page happens in ASP.NET Core authentication, before MentorAgent sees the user. A claim that only your own AuthenticationStateProvider adds is not seen on the hub, on /mentor/chat or over A2A: add it with an IClaimsTransformation instead.
JWT bearer tokens
Section titled “JWT bearer tokens”With the default settings of JwtBearer, the role and roles claims of a token become ClaimTypes.Role claims, and IsInRole finds them with no extra setting. The Secure the endpoints page uses these defaults.
Many applications turn this mapping off, with MapInboundClaims = false, to keep the claim names of the token, such as sub and name. Then the roles stay in a roles claim, and the identity must be told about it:
using Microsoft.AspNetCore.Authentication; // IClaimsTransformation
using Microsoft.AspNetCore.Authentication.JwtBearer; // JwtBearerDefaultsbuilder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
// Keep the claim names of the token: "sub", "name", "roles"
options.MapInboundClaims = false;
// and name the claims that hold the user's name and roles
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "roles";
});Without RoleClaimType = "roles", IsInRole looks for ClaimTypes.Role, finds nothing, and every action with RequiredRoles stays refused. NameClaimType does the same for the user's name, which MentorAgent uses to identify the user when the token has no sub or oid.
Microsoft Entra ID
Section titled “Microsoft Entra ID”App roles
Section titled “App roles”App roles are the simplest way to bring roles from Microsoft Entra ID. You define them in the app registration of your API, under App roles, and assign users or groups to them in the enterprise application. Entra ID then puts the user's roles in the roles claim of the access token, by their value, such as Supervisors. Use the same values in RequiredRoles.
For an API that validates Entra ID access tokens with JwtBearer:
var tenant = builder.Configuration["Entra:TenantId"];
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Access tokens that Microsoft Entra ID issues for your API
options.Authority =
$"https://login.microsoftonline.com/{tenant}/v2.0";
options.Audience = builder.Configuration["Entra:ClientId"];
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "name";
// App roles arrive in "roles"
options.TokenValidationParameters.RoleClaimType = "roles";
});
// Group object IDs become role names: see EntraGroupRoles
builder.Services.AddTransient<IClaimsTransformation, EntraGroupRoles>();- The audience is the client ID of your API's app registration, the value that Entra ID writes in the
audclaim of version 2.0 tokens. - Another library that validates the token, such as Microsoft.Identity.Web, follows the same rule: the identity's
RoleClaimTypemust be the claim that holds the roles. If a user who holds the role is refused, check it as in Common errors. - The last line registers the group mapping below. Leave it out if you use app roles only.
Groups
Section titled “Groups”You can also ask Entra ID for the user's groups, in the app registration under Token configuration. The groups claim then lists them, but by object ID, a value such as 11111111-1111-1111-1111-111111111111, never by name. An object ID in RequiredRoles would work, but it says nothing to a reader of your code. Map the IDs to role names instead, with an IClaimsTransformation:
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication; // IClaimsTransformation
// Gives the members of some Entra ID groups a role of your application.
// The token lists the groups by object ID, never by name.
public sealed class EntraGroupRoles(
IConfiguration configuration, ILogger<EntraGroupRoles> logger)
: IClaimsTransformation
{
const string Source = "EntraGroupRoles";
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
// ASP.NET Core can call this more than once for the same user
if (principal.Identities.Any(i => i.AuthenticationType == Source))
return Task.FromResult(principal);
// Above the limit, the token points to Microsoft Graph instead
if (principal.HasClaim(c => c.Type == "hasgroups"
|| c.Type == "_claim_names" && c.Value.Contains("\"groups\"")))
{
logger.LogWarning("Too many groups in the token: read them " +
"from Microsoft Graph, or assign groups to the application.");
return Task.FromResult(principal);
}
// Configuration "Roles:Groups": group object ID -> role name
var map = configuration.GetSection("Roles:Groups");
var roles = principal.FindAll("groups")
.Select(group => map[group.Value])
.OfType<string>()
.Distinct()
.Select(role => new Claim(ClaimTypes.Role, role))
.ToList();
if (roles.Count == 0)
return Task.FromResult(principal);
// The roles go in an identity of their own, next to the token's
var withRoles = principal.Clone();
withRoles.AddIdentity(new ClaimsIdentity(
roles, Source, ClaimTypes.Name, ClaimTypes.Role));
return Task.FromResult(withRoles);
}
}The mapping goes in your configuration:
{
"Roles": {
"Groups": {
"11111111-1111-1111-1111-111111111111": "Supervisors"
}
}
}- The roles go in an identity of their own, next to the one from the token, with
ClaimTypes.Roleas its role claim.IsInRolereads every identity of the user, so it finds them. - ASP.NET Core can call the transformation more than once for the same user, so it checks first whether it has already run.
- Above a limit, the token has no
groupsclaim. When a user belongs to more than 200 groups, Entra ID leaves thegroupsclaim out of a JWT (150 in a SAML token). It writes_claim_namesand_claim_sourcesinstead, andhasgroupsin the implicit flow. The transformation above then gives no role and logs a warning. Either read the groups from Microsoft Graph in your transformation, or set the groups claim to Groups assigned to the application: the token then lists only the groups assigned to your application, of which the user is a direct member.
App roles have no such limit, and their values are names: prefer them when you can.
Windows and Active Directory
Section titled “Windows and Active Directory”With Windows authentication, the user is a WindowsPrincipal, and its roles are the user's Windows groups. Write a group in RequiredRoles as DOMAIN\Group:
public sealed class CaseRefunds
{
// A Windows group, written as DOMAIN\Group
[MentorAction(Description = "Approves the refund of a support case",
RequiredRoles = [@"CONTOSO\Case Supervisors"],
RequiresConfirmation = true)]
public string ApproveCaseRefund(string caseId) =>
$"Refund approved for {caseId}";
}WindowsPrincipal.IsInRole resolves the name against the user's groups, the nested ones included. The model is not told these groups: see How MentorAgent checks a role.
The server signs the user in. Under IIS, with the app in the IIS process (the default), turn on Windows authentication for the site and use the IIS scheme:
using Microsoft.AspNetCore.Server.HttpSys; // HttpSysDefaults and others
using Microsoft.AspNetCore.Server.IIS; // IISServerDefaults
using Novior.MentorAgent.Attributes;// IIS signs the user in; the app runs in the IIS process
builder.Services.AddAuthentication(IISServerDefaults.AuthenticationScheme);<!-- web.config -->
<system.webServer>
<security>
<authentication>
<anonymousAuthentication enabled="false" />
<windowsAuthentication enabled="true" />
</authentication>
</security>
</system.webServer>Without IIS, on Windows, HTTP.sys signs the user in itself:
// HTTP.sys signs the user in, without IIS
builder.WebHost.UseHttpSys(options =>
{
options.Authentication.Schemes =
AuthenticationSchemes.Negotiate | AuthenticationSchemes.NTLM;
options.Authentication.AllowAnonymous = false;
});
builder.Services.AddAuthentication(HttpSysDefaults.AuthenticationScheme);On Kestrel, and on Linux or macOS, Windows authentication comes from the Microsoft.AspNetCore.Authentication.Negotiate package. Add it to your project:
dotnet add package Microsoft.AspNetCore.Authentication.Negotiateusing Microsoft.AspNetCore.Authentication.Negotiate; // NegotiateDefaults// Kestrel signs the user in with Kerberos or NTLM, on any system
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate(options =>
{
// On Linux and macOS a Kerberos sign-in brings no groups:
// read them from Active Directory
if (!OperatingSystem.IsWindows())
options.EnableLdap(builder.Configuration["ActiveDirectory:Domain"]
?? throw new InvalidOperationException(
"Set ActiveDirectory:Domain, for example contoso.com."));
});
// Every request signs in, so every user has their groups
builder.Services.AddAuthorization(options =>
options.FallbackPolicy = options.DefaultPolicy);- On Windows the user's groups come with the sign-in, as with IIS and HTTP.sys.
- On Linux and macOS a Kerberos sign-in brings no groups. The handler reads them from Active Directory only with
EnableLdap, as above. The host also needs a keytab for its service account: see Configure Windows Authentication in ASP.NET Core (external site). - Every request signs in. Without the fallback policy, a request that does not ask for authentication stays anonymous, and an action with
RequiredRolesis refused.
Roles from your own database
Section titled “Roles from your own database”When your application keeps the roles itself, for example in a table per user, add them to the signed-in user with an IClaimsTransformation. ASP.NET Core runs it on every authenticated request, so the roles reach the hub, /mentor/chat, A2A and a Blazor Server circuit alike:
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication; // IClaimsTransformation
// Your own list of roles per user, for example a table in your database
public interface ICaseRoleStore
{
Task<IReadOnlyList<string>> GetRolesAsync(string userId);
}
// Adds the roles that your application keeps for the signed-in user
public sealed class RolesFromStore(ICaseRoleStore store) : IClaimsTransformation
{
const string Source = "RolesFromStore";
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity?.IsAuthenticated != true
|| principal.Identities.Any(i => i.AuthenticationType == Source))
return principal;
// The user's ID from the sign-in: NameIdentifier, or "oid" or "sub"
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst("oid")?.Value
?? principal.FindFirst("sub")?.Value;
if (userId is null)
return principal;
var roles = await store.GetRolesAsync(userId);
var withRoles = principal.Clone();
withRoles.AddIdentity(new ClaimsIdentity(
roles.Select(role => new Claim(ClaimTypes.Role, role)),
Source, ClaimTypes.Name, ClaimTypes.Role));
return withRoles;
}
}Register it with your other services:
builder.Services.AddScoped<IClaimsTransformation, RolesFromStore>();- It runs on every request, so keep it fast: cache the roles if your store is slow.
- A connection keeps the user it started with. The hub reads the user when the connection opens, and a Blazor Server circuit when it starts. A role that you change reaches an open connection when it reconnects.
- ASP.NET Core uses one
IClaimsTransformation. To combine the group mapping and your database, do both in one class.
Common errors
Section titled “Common errors”- A user who holds the role is refused, with a warning in the log for the refused call. The roles are in a claim that the identity does not name as its role claim, such as
roleswithMapInboundClaims = false. SetRoleClaimType. To see what the user has, logUser.Identities.Select(i => i.RoleClaimType)and the claims once, in a test endpoint. - It works with a different case. Role names in claims are case-sensitive: write them in
RequiredRolesas the token writes them. - It works on the page but not on the hub, on
/mentor/chator over A2A. Your ownAuthenticationStateProvideradds the roles, and these endpoints do not ask it. Move the code to anIClaimsTransformation. - Entra ID groups give no role. The
groupsclaim holds object IDs, not names: map them as in Groups. If the token has_claim_namesand nogroups, the user is over the group limit. - Windows groups give no role on Linux. Negotiate needs
EnableLdapto read the groups there. - Every action with
RequiredRolesis refused for everybody. Nobody is signed in on that channel: the endpoint does not require authentication, or the request carries no credentials. See Secure the endpoints.
See also
Section titled “See also”- Roles, confirmation and governance — Role checks and human-in-the-loop approval for AI agent tools in .NET, enforced in code on every channel and not in the prompt, and what stays in your code.
- Secure the endpoints — Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.
- MCP server — Expose the methods of your .NET app as MCP tools for Claude Desktop, VS Code and other MCP clients, with authentication, role checks and writes withheld.
- Troubleshooting — Fix common problems with MentorAgent: startup errors, blocked requests, roles, confirmation, local models. And where to get help or report a bug.
