diff --git a/src/CrestApps.Core.Docs/docs/core/signalr.md b/src/CrestApps.Core.Docs/docs/core/signalr.md index 85bdc4b8..c2c07d47 100644 --- a/src/CrestApps.Core.Docs/docs/core/signalr.md +++ b/src/CrestApps.Core.Docs/docs/core/signalr.md @@ -164,8 +164,164 @@ 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, or resolve the caller through `IUserAccessor`, rather than letting it resolve the user itself. +::: + +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 + 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: