Questa pagina non è ancora tradotta in italiano: qui la trovi in inglese.
Secure the endpoints
Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.
On this page
Use this page to protect the endpoints that MentorAgent maps. Every one of them is open until you protect it, because the package cannot know how your application signs users in. This page shows how to require authentication on each endpoint, and where to put Cross-Origin Resource Sharing (CORS) in the pipeline. A paid turn is a turn that uses your model, so you pay for it.
When you need it
Section titled “When you need it”Do it before anyone but you can reach the server. It applies to the endpoints of Novior.MentorAgent.Server: the hub, /mentor/chat, /mcp and /a2a. On Blazor Server the widget talks through the circuit, which your app's authentication already covers: only the endpoints of the Model Context Protocol (MCP) server and the Agent-to-Agent (A2A) server are left, if you map them.
What each endpoint exposes
Section titled “What each endpoint exposes”| Endpoint | Open by default | What an anonymous caller can do | Protect it with |
|---|---|---|---|
/mentor-hub | Yes | Run turns on your model | configureTransports |
GET and POST /mentor/chat | Yes | Run turns on your model | configureTransports |
POST /mentor/approve, POST /mentor/cancel, /mentor/session | Partly: while a signed-in user's connection is open, only that user is accepted | Nothing, while a signed-in user's connection is open | Built in |
GET /mentor/admin/metrics | No | Nothing: the endpoint requires the DashboardRole role | DashboardRole |
GET /mentor/tour | Yes, on purpose | Read the tour: public screen names, the same for everyone | Nothing to do |
/mcp | Yes | Call every action that has no roles and does not ask for confirmation | MapMentorAgentMcp(configure: …) |
/a2a and /.well-known/agent-card.json | Yes | Run tasks on your model and read the card | MapMentorAgentA2A(configure: …) |
Install the packages
Section titled “Install the packages”The endpoints come with Novior.MentorAgent.Server: the hub, Server-Sent Events (SSE), the MCP server and the A2A server. Authentication comes from ASP.NET Core. For JSON Web Token (JWT) bearer tokens, add the JwtBearer package to the server project, with the same major version as your .NET:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerMinimal configuration
Section titled “Minimal configuration”Choose the authentication scheme
Section titled “Choose the authentication scheme”Keep the scheme your application already has: cookies work for a WebAssembly client served by the same host. For clients on other origins, mobile apps and other agents, use bearer tokens:
using System.Security.Claims; // ClaimTypes
using System.Threading.RateLimiting; // RateLimitPartition
using Novior.MentorAgent.Extensions; // MapMentorAgentMcp and A2A
using Novior.MentorAgent.Server.Extensions; // MapMentorAgentServer
using Microsoft.AspNetCore.Authentication.JwtBearer; // JwtBearerEventsbuilder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Your identity provider
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
// A WebSocket handshake cannot carry a header:
// SignalR sends the token as ?access_token=
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var token = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(token)
&& context.Request.Path.StartsWithSegments("/mentor-hub"))
context.Token = token;
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();In a browser, WebSocket and Server-Sent Events connections cannot carry an Authorization header. So the client (JavaScript or Blazor WebAssembly) sends the token in the access_token query string. Outside a browser, the .NET and Java clients send the header. The OnMessageReceived event above reads the query string for the hub only: every other endpoint still needs the header. If you map the hub on another path, change /mentor-hub in the event too.
Require authorization on each endpoint
Section titled “Require authorization on each endpoint”After Build(), with MentorAgent and AddMentorAgentServer() registered as on the other pages:
// after var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// The hub and both /mentor/chat
app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());
// /mcp
app.MapMentorAgentMcp(configure: e => e.RequireAuthorization());
// /a2a and the agent card
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization());configureTransportscovers the hub and both/mentor/chatendpoints, the three that run a turn.- MCP and A2A are mapped by their own calls, so
configureTransportsdoes not reach them. Theirconfigurecallback does, and for A2A it covers the card too. - Leave out the lines for the endpoints you do not map.
MapMentorAgentMcp()andMapMentorAgentA2A()also needAddMentorAgentMcp()andAddMentorAgentA2A().AddMentorAgentServer()calls both.
Then send the credentials from each client: accessTokenFactory in a JavaScript client, AccessTokenProvider in a WebAssembly client, and the Headers of a RemoteAgents entry in another MentorAgent host.
Complete configuration
Section titled “Complete configuration”A server with browser clients on another origin and other programs on /mcp and /a2a. The services go next to the JWT registration above:
// Browser clients on other origins: SignalR sends credentials, so list the origins
builder.Services.AddCors(cors => cors.AddPolicy("MentorClients", policy => policy
.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()));
// Other programs call /mcp and /a2a: a limit per caller
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.AddPolicy("agents", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
}));
});
// after var app = builder.Build();
app.UseCors("MentorClients"); // before authentication and authorization
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());
app.MapMentorAgentMcp(configure: e => e
.RequireAuthorization()
.RequireRateLimiting("agents"));
app.MapMentorAgentA2A(configure: e => e
.RequireAuthorization()
.RequireRateLimiting("agents"));- CORS comes first. Call
UseCorsbeforeUseAuthenticationandUseAuthorization, and write those two calls yourself. Otherwise the browser's preflight request, which carries no credentials, can be refused before CORS answers it. - One CORS policy.
UseCors("MentorClients")applies to every endpoint without a policy of its own: the hub, the/mentorendpoints,/a2aand/mcp. A browser client of/mcpalso needs theMcp-Session-Idheader exposed: MCP server shows a policy of its own for that endpoint. - A limit for programs.
RateLimitPerUsercounts an MCP call in an allowance of its own, apart from chat messages. It counts an A2A task only when the request is authenticated. The ASP.NET Core rate limiter in this example bounds the requests to both endpoints, per caller, whatever MentorAgent sees.
Options for endpoint security
Section titled “Options for endpoint security”| Option | Why it matters for the endpoints |
|---|---|
AnonymousIdentity | With the default, PerSession, every hub connection, every SSE request and every A2A task of an anonymous caller is a new user, with its own memory and its own count. Shared puts every anonymous caller on one key: single-user hosts only |
RateLimitPerUser, RateLimitWindowSecs | Counted by the signed-in user's NameIdentifier claim or, if that claim is missing, by sub, oid or the name. MCP calls have an allowance of their own. An anonymous caller gets a new allowance with every connection, every SSE request and every A2A task, so only authentication bounds the spend |
UseMemoryContext, MemoryAutoCapture | With either on and /mentor/chat open to anyone, MentorAgent logs a warning at startup |
DashboardRole | The role that GET /mentor/admin/metrics requires. An empty value opens it: development only |
McpCallerPrincipal | Which role-gated actions an authenticated MCP caller sees |
MaxMessageLength | Longer messages are refused before the model is called |
Every option is in Configuration options, and the limits under Security and limits.
Security and roles
Section titled “Security and roles”- Who the user is.
AddMentorAgentServer()passes the user of the hub connection, or of the request, to MentorAgent. The user's id is theNameIdentifierclaim or, if that claim is missing,sub,oidor the name.RequiredRolesis checked withIsInRole. With JWT bearer, the default claim mapping turns the token'ssubandroleclaims intoNameIdentifierandClaimTypes.Role. WithMapInboundClaims = false, the roles needTokenValidationParameters.RoleClaimType: see MCP server. - Apps with Razor components.
AddRazorComponents()andAddMentorAgentServer()can come in either order. MentorAgent reads the user of the hub, of/mentor/chatand of A2A from the request. It does this before it asks Blazor's ownAuthenticationStateProvider, which works only inside a circuit. - Adding claims or roles. To add claims or roles for these endpoints, use an
IClaimsTransformation. MentorAgent does not use your ownAuthenticationStateProvideron these endpoints. - Roles from JWT, Microsoft Entra ID or Windows. How the roles of each kind of sign-in reach
RequiredRoles, with the claim settings and the group mapping: see Roles from your sign-in. - Without authentication, role-gated actions are refused. Every action with
RequiredRolesis refused for an anonymous caller. /mentor/approve,/mentor/canceland/mentor/sessioncheck the owner. While a signed-in user's connection is open, an anonymous request gets401and another user gets403. Send the same token, or the same cookies withcredentials: 'include'. The owner is the user with the same id: theNameIdentifierclaim or, if that claim is missing,sub,oidor the name. An id made from a name starts withname:, so a user name never matches the id of another user. The owner is checked before the body of a/mentor/sessionrequest is read.- The session snapshot is protected.
GET /mentor/sessionreturns the conversation protected with ASP.NET Core Data Protection and bound to the connection's user. The client cannot read or change it, and only that user can restore it. On a host without sign-in every visitor is the same anonymous user, so there the snapshot is bound to the application only. To restore after a restart, or on another instance, persist and share the ASP.NET Core Data Protection key ring (external site), as you do for authentication cookies. - A confirmation raised over SSE has no owner. Whoever holds its
actionIdcan answer it. Keep/mentor/chatauthenticated and treat theactionIdas a secret. - A confirmation does not outlive its connection. When the hub connection closes, the turn waiting for the confirmation ends with it. A late
POST /mentor/approveanswers404and the action does not run. - The dashboard component checks nothing.
DashboardRoleprotects the endpoint, notMentorDashboard: put the component on a page with[Authorize(Roles = "Admin")].
What each channel does with roles and confirmations is in Where nobody can answer.
Common errors
Section titled “Common errors”- The hub refuses a valid token with
401, or treats the user as anonymous. The server does not readaccess_tokenfrom the query string for the hub's path: add theOnMessageReceivedevent. - The browser blocks the connection from another origin. The origin is not in the policy, the policy lacks
AllowCredentials(), orUseCorscomes after authorization. - Every request to
/mcpanswers 500. The endpoint hasRequireCors(...)and the pipeline has noapp.UseCors(). /mentor/approve,/mentor/cancelor/mentor/sessionanswer401or403to the user who owns the connection. The request does not come from that user: send the same token or the same cookies as the hub connection. Or the token has none of the claimsNameIdentifier,sub,oidor name.POST /mentor/approveanswers404. Nothing waits for the answer any more: it was answered already, the turn was stopped, or the connection closed. It is not a fault: close the dialog.GET /mentor/admin/metricsanswers403. The user lacks theDashboardRolerole,Adminunless you change it.- At startup the log says that
/mentor/chatruns a paid turn for anyone who can reach it. The transports have noconfigureTransports: e => e.RequireAuthorization(). /mcpor/a2aanswers429. The caller went past the allowance of the ASP.NET Core rate limiter. A call to/mcpthat went pastRateLimitPerUserdoes not answer429: it answers200with anisErrorresult.POST /mentor/sessionanswers400. The snapshot is not one that this server issued for this user. Possible causes: the snapshot was edited, it belongs to another user, an older version saved it, or the server lost the Data Protection keys that protected it. Save the conversation again withGET /mentor/session. The live conversation stays untouched.POST /mentor/sessionanswers415. The request does not have a JSON content type.
Vedi anche
Sezione “Vedi anche”- Ruoli, conferma e governance — Ruoli e approvazione human-in-the-loop per i tool di un agente AI in .NET: controlli nel codice, non nel prompt, su ogni canale, e cosa resta a te.
- Any front end: React, Angular, Vue — Build the chat in React, Angular, Vue or a mobile app with a .NET AI agent behind it: SignalR or Server-Sent Events, confirmation over HTTP, stopping an answer.
- 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.
- Configuration options — Every MentorOptions property with its type, default and effect, grouped by area, plus the options of the WebAssembly client and the YAML loader.
