MentorAgent Docs
Menu

How-to guidePackages: Novior.MentorAgent, Novior.MentorAgent.Server

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.

View as Markdown
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.

Every example on this page uses this action:

CaseApprovals.cs
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 in RequiredRoles is enough. The user must be signed in: for an anonymous user, the action is refused.
  • IsInRole reads the claim that each identity names as its role claim, its RoleClaimType. By default that is ClaimTypes.Role. A roles claim counts only when the identity names roles as its role claim.
  • Role names in claims are case-sensitive. supervisors does not match Supervisors.
  • 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 fromThe 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 circuitThe user that the AuthenticationStateProvider reports
The /mcp endpointThe 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.

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:

Program.cs
using Microsoft.AspNetCore.Authentication;           // IClaimsTransformation
using Microsoft.AspNetCore.Authentication.JwtBearer; // JwtBearerDefaults
Program.cs
builder.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.

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:

Program.cs
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 aud claim of version 2.0 tokens.
  • Another library that validates the token, such as Microsoft.Identity.Web, follows the same rule: the identity's RoleClaimType must 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.

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:

EntraGroupRoles.cs
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:

JSON
{
  "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.Role as its role claim. IsInRole reads 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 groups claim. When a user belongs to more than 200 groups, Entra ID leaves the groups claim out of a JWT (150 in a SAML token). It writes _claim_names and _claim_sources instead, and hasgroups in 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.

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:

CaseRefunds.cs
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:

Program.cs
using Microsoft.AspNetCore.Server.HttpSys;   // HttpSysDefaults and others
using Microsoft.AspNetCore.Server.IIS;       // IISServerDefaults
using Novior.MentorAgent.Attributes;
Program.cs
// IIS signs the user in; the app runs in the IIS process
builder.Services.AddAuthentication(IISServerDefaults.AuthenticationScheme);
XML
<!-- 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:

Program.cs
// 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:

Terminal
dotnet add package Microsoft.AspNetCore.Authentication.Negotiate
Program.cs
using Microsoft.AspNetCore.Authentication.Negotiate;   // NegotiateDefaults
Program.cs
// 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 RequiredRoles is refused.

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:

RolesFromStore.cs
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:

Program.cs
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.
  • 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 roles with MapInboundClaims = false. Set RoleClaimType. To see what the user has, log User.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 RequiredRoles as the token writes them.
  • It works on the page but not on the hub, on /mentor/chat or over A2A. Your own AuthenticationStateProvider adds the roles, and these endpoints do not ask it. Move the code to an IClaimsTransformation.
  • Entra ID groups give no role. The groups claim holds object IDs, not names: map them as in Groups. If the token has _claim_names and no groups, the user is over the group limit.
  • Windows groups give no role on Linux. Negotiate needs EnableLdap to read the groups there.
  • Every action with RequiredRoles is 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.
  • 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.