Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src/CrestApps.Core.Docs/docs/core/signalr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TClient>` 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<IAIChatHubClient>
{
protected override Task<bool> 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<TClient>` | `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<NotificationHub>("/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:
Expand Down
Loading