From c9db3ff5c159757f08c46c830390920e8b842954 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 28 Jul 2026 21:44:26 +0300 Subject: [PATCH 1/2] Document hub authentication and authorization The SignalR page covered route management but said nothing about how a hub authenticates or authorizes its callers, even though the chat hubs ship with authorization hooks that permit everything until they are overridden. - Describe the connection level and per-invocation authorization models, and list the hook each chat hub exposes along with its permissive default. - Show how headless clients authenticate with a bearer token, including the `access_token` query string that SignalR falls back to during a WebSocket handshake. - Explain when to require a scheme listing policy on a hub, and why that policy must not be applied to a hub that also accepts anonymous callers. - Warn against resolving the caller through `IHttpContextAccessor` inside a hub invocation, and point to `Context.User` instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/CrestApps.Core.Docs/docs/core/signalr.md | 139 +++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/CrestApps.Core.Docs/docs/core/signalr.md b/src/CrestApps.Core.Docs/docs/core/signalr.md index 85bdc4b8..e339632f 100644 --- a/src/CrestApps.Core.Docs/docs/core/signalr.md +++ b/src/CrestApps.Core.Docs/docs/core/signalr.md @@ -164,8 +164,147 @@ public class MyService(HubRouteManager hubRouteManager) } ``` +## Authentication and Authorization + +`AddCoreSignalR()` does not configure authentication. Hubs participate in the host's existing ASP.NET Core authentication pipeline, so `app.UseAuthentication()` must run before endpoint routing resolves the hub, exactly as it does for controllers and minimal API endpoints. + +### Two Authorization Models + +CrestApps hubs support two models, and you can combine them. + +**Connection-level** — apply `[Authorize]` to the hub class. The connection is rejected during the negotiate request when the caller fails the policy. Use it when every method on the hub requires the same identity. + +```csharp +[Authorize] +public sealed class NotificationHub : Hub +{ +} +``` + +**Per-invocation** — allow the connection, then authorize each call against the resource it targets. The chat hubs use this model because a single connection can address several profiles or interactions, each with its own access rules. `AIChatHubCore` and `ChatInteractionHubBase` are not decorated with `[Authorize]`, and their authorization hooks return `true` by default, so you must override them to enforce access: + +```csharp +[AllowAnonymous] +public sealed class AIChatHub : AIChatHubCore +{ + protected override Task AuthorizeProfileAsync(IServiceProvider services, AIProfile profile) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(profile); + + return Task.FromResult(MyAccessRules.CanAccessProfile(Context.User, profile.ItemId)); + } +} +``` + +| Hub | Hook to override | Default | +| --- | --- | --- | +| `AIChatHubCore` | `AuthorizeProfileAsync(IServiceProvider, AIProfile)` | Returns `true` | +| `ChatInteractionHubBase` | `AuthorizeAsync(IServiceProvider, ChatInteraction)` | Returns `true` | + +When a hook returns `false`, the hub sends `ReceiveError` to the caller with the message from `GetNotAuthorizedMessage()` instead of throwing, so the client stays connected and can retry with a different resource. + +:::warning +The default implementations permit everything. A hub that is reachable anonymously and does not override its hook grants every caller access to every profile or interaction. Override the hook, apply `[Authorize]`, or both. +::: + +### Authenticating Headless Clients with Access Tokens + +Browser clients that already hold an authentication cookie need nothing extra, because the cookie is sent with the negotiate request and the WebSocket handshake. + +Headless clients — single page applications, mobile applications, and service-to-service callers — send a bearer token instead. Browsers cannot set an `Authorization` header on a WebSocket handshake, so SignalR clients fall back to the standard `access_token` query string parameter. Configure the bearer handler to read it: + +```csharp +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = "https://identity.example.com"; + options.Audience = "chat-api"; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + + if (!string.IsNullOrEmpty(accessToken) && + context.HttpContext.Request.Path.StartsWithSegments("/Communication/Hub")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + }, + }; + }); +``` + +The path check keeps the query string token scoped to hub endpoints, so it is not accepted on the rest of the site. + +Clients supply the token through `accessTokenFactory` or `AccessTokenProvider`: + +```javascript +const connection = new signalR.HubConnectionBuilder() + .withUrl(chatHubUrl, { + accessTokenFactory: () => accessToken, + }) + .withAutomaticReconnect() + .build(); +``` + +```csharp +var connection = new HubConnectionBuilder() + .WithUrl(chatHubUrl, options => + { + options.AccessTokenProvider = () => Task.FromResult(accessToken); + }) + .Build(); +``` + +### Accepting Several Schemes on One Hub + +When a hub must accept both a cookie and a token, build a policy that names each scheme and require it on the hub endpoint. Listing the schemes explicitly matters, because a policy evaluates only the schemes it names: + +```csharp +builder.Services.AddAuthorizationBuilder() + .AddPolicy("HubAccess", policy => + { + policy.AddAuthenticationSchemes( + JwtBearerDefaults.AuthenticationScheme, + CookieAuthenticationDefaults.AuthenticationScheme); + policy.RequireAuthenticatedUser(); + }); +``` + +```csharp +app.MapHub("/Communication/Hub/NotificationHub") + .RequireAuthorization("HubAccess"); +``` + +:::caution +Do not attach a scheme-listing policy to a hub that must also accept anonymous connections. When none of the named schemes succeed, the authorization middleware replaces `HttpContext.User` with an empty principal, which discards an identity that a scheme outside the policy had already established. For anonymous-capable hubs, register the token handler in the default authenticate scheme chain instead, so `UseAuthentication()` populates the user without any policy. +::: + +### Reading the Caller's Identity Inside a Hub + +Use `Context.User` for the caller's identity and `Context.GetHttpContext()` when you need the request that opened the connection. + +```csharp +var user = Context.User; +var httpContext = Context.GetHttpContext(); +``` + +:::danger +Do not resolve the caller through `IHttpContextAccessor` in code that runs during a hub invocation. SignalR dispatches hub methods outside the request pipeline, so `IHttpContextAccessor.HttpContext` is unreliable and is frequently `null`, particularly over WebSockets and when running behind a SignalR backplane or Azure SignalR Service. See [Use HttpContext in ASP.NET Core SignalR](https://learn.microsoft.com/aspnet/core/signalr/httpcontext). + +This applies to services you call from a hub as well. A service that reads `IHttpContextAccessor.HttpContext?.User` behaves differently when it is invoked from a controller than when it is invoked from a hub. If such a service makes a security decision, pass `Context.User` into it explicitly rather than letting it resolve the user itself. +::: + +This matters for tool authorization. `IAIToolAccessEvaluator` receives the `ClaimsPrincipal` that the completion pipeline resolved, and tools the caller is not authorized to use are removed from the request and reported in a warning log entry. See [Tools](./tools) for the evaluator contract and the logging behavior. + ## Scale-out with Redis Backplane + By default, SignalR keeps all connection state in-memory on a single server. In a multi-server deployment, messages sent on one server won't reach clients connected to another server. The solution is a **Redis backplane**, which broadcasts SignalR messages across all servers: From bf99e856a9db622d88cea0523738d70c13e6e98c Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 29 Jul 2026 00:43:34 +0300 Subject: [PATCH 2/2] Recommend IUserAccessor for resolving the caller inside hubs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/CrestApps.Core.Docs/docs/core/signalr.md | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/CrestApps.Core.Docs/docs/core/signalr.md b/src/CrestApps.Core.Docs/docs/core/signalr.md index e339632f..c2c07d47 100644 --- a/src/CrestApps.Core.Docs/docs/core/signalr.md +++ b/src/CrestApps.Core.Docs/docs/core/signalr.md @@ -297,10 +297,27 @@ var httpContext = Context.GetHttpContext(); :::danger Do not resolve the caller through `IHttpContextAccessor` in code that runs during a hub invocation. SignalR dispatches hub methods outside the request pipeline, so `IHttpContextAccessor.HttpContext` is unreliable and is frequently `null`, particularly over WebSockets and when running behind a SignalR backplane or Azure SignalR Service. See [Use HttpContext in ASP.NET Core SignalR](https://learn.microsoft.com/aspnet/core/signalr/httpcontext). -This applies to services you call from a hub as well. A service that reads `IHttpContextAccessor.HttpContext?.User` behaves differently when it is invoked from a controller than when it is invoked from a hub. If such a service makes a security decision, pass `Context.User` into it explicitly rather than letting it resolve the user itself. +This applies to services you call from a hub as well. A service that reads `IHttpContextAccessor.HttpContext?.User` behaves differently when it is invoked from a controller than when it is invoked from a hub. If such a service makes a security decision, pass `Context.User` into it explicitly, or resolve the caller through `IUserAccessor`, rather than letting it resolve the user itself. ::: -This matters for tool authorization. `IAIToolAccessEvaluator` receives the `ClaimsPrincipal` that the completion pipeline resolved, and tools the caller is not authorized to use are removed from the request and reported in a warning log entry. See [Tools](./tools) for the evaluator contract and the logging behavior. +For services that cannot take the principal as a parameter, the framework provides `IUserAccessor`. It returns the principal the current hub invocation is running as, and falls back to `IHttpContextAccessor.HttpContext?.User` when no hub invocation is in progress. The built-in AI chat hubs publish the connection's principal for the duration of every invocation, so a service that reads `IUserAccessor.User` sees the same caller whether it was reached over SignalR or over a regular HTTP request. See [Tools](./tools) for the accessor contract and how to publish the principal from a custom hub. + +```csharp +public sealed class MyService +{ + private readonly IUserAccessor _userAccessor; + + public MyService(IUserAccessor userAccessor) + { + _userAccessor = userAccessor; + } + + public bool IsCallerAuthenticated() + => _userAccessor.User?.Identity?.IsAuthenticated == true; +} +``` + +This matters for tool authorization. `IAIToolAccessEvaluator` receives the `ClaimsPrincipal` that the completion pipeline resolved through `IUserAccessor`, and tools the caller is not authorized to use are removed from the request and reported in a warning log entry. See [Tools](./tools) for the evaluator contract and the logging behavior. ## Scale-out with Redis Backplane