ASP.NET Core SignalR Hub — Groups, Authentication and Method Invocation

📋 Table of Contents
  1. BlogHub Implementation
  2. Common Mistakes

SignalR is ASP.NET Core’s real-time communication library — it abstracts WebSockets (with fallbacks to Server-Sent Events and long polling) behind a simple hub/client model. A Hub is the server-side class that receives method calls from clients and can invoke methods on connected clients. Using a strongly-typed client interface (Hub<IBlogHubClient>) gives compile-time safety for the method names the server calls on clients.

BlogHub Implementation

// ── Strongly-typed client interface ───────────────────────────────────────
public interface IBlogHubClient
{
    Task ReceiveComment(CommentDto comment);
    Task UpdateViewerCount(int postId, int count);
    Task ReceiveNotification(NotificationDto notification);
    Task CommentStatusChanged(int commentId, string status);
}

// ── Hub implementation ────────────────────────────────────────────────────
[Authorize]  // all hub connections require JWT auth (public methods override below)
public class BlogHub : Hub<IBlogHubClient>
{
    private readonly ICommentsService _comments;
    private readonly INotificationService _notifications;
    private static readonly ConcurrentDictionary<string, int> _roomViewers = new();

    // ── Connection lifecycle ──────────────────────────────────────────────
    public override async Task OnConnectedAsync()
    {
        await base.OnConnectedAsync();
        // Re-join any groups the user was in before disconnection (if stored)
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // Clean up any room memberships
        await base.OnDisconnectedAsync(exception);
    }

    // ── Client-invokable hub methods ──────────────────────────────────────

    // Join a post's real-time room (public — no [Authorize] needed)
    [AllowAnonymous]
    public async Task JoinPostRoom(int postId)
    {
        var groupName = $"post-{postId}";
        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);

        // Increment and broadcast viewer count
        var count = _roomViewers.AddOrUpdate(groupName, 1, (_, c) => c + 1);
        await Clients.Group(groupName).UpdateViewerCount(postId, count);
    }

    [AllowAnonymous]
    public async Task LeavePostRoom(int postId)
    {
        var groupName = $"post-{postId}";
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);

        var count = _roomViewers.AddOrUpdate(groupName, 0, (_, c) => Math.Max(0, c - 1));
        await Clients.Group(groupName).UpdateViewerCount(postId, count);
    }

    // Submit a comment (requires auth)
    public async Task SendComment(int postId, string body)
    {
        var authorId = Context.UserIdentifier!;

        // Save via service (also validates ownership etc.)
        var comment  = await _comments.CreateAsync(postId, authorId, body);

        // Broadcast to all viewers of this post
        await Clients.Group($"post-{postId}").ReceiveComment(comment);

        // Notify the post author if different from commenter
        if (comment.PostAuthorId != authorId)
            await Clients.User(comment.PostAuthorId).ReceiveNotification(
                new NotificationDto($"New comment on '{comment.PostTitle}'",
                                    $"/posts/{comment.PostSlug}"));
    }
}

// ── Program.cs — SignalR setup ─────────────────────────────────────────────
builder.Services.AddSignalR(opts =>
{
    opts.EnableDetailedErrors = builder.Environment.IsDevelopment();
    opts.KeepAliveInterval    = TimeSpan.FromSeconds(15);
    opts.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
});

// ── JWT for WebSocket connections — can't use Authorization header ─────────
// WebSockets cannot set custom headers — JWT is passed as query param
builder.Services.Configure<JwtBearerOptions>(
    JwtBearerDefaults.AuthenticationScheme, opts =>
{
    opts.Events = new JwtBearerEvents
    {
        OnMessageReceived = ctx =>
        {
            var token = ctx.Request.Query["access_token"];
            var path  = ctx.HttpContext.Request.Path;
            if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hubs"))
                ctx.Token = token;
            return Task.CompletedTask;
        }
    };
});

// In middleware pipeline:
app.MapHub<BlogHub>("/hubs/blog");
Note: WebSocket connections cannot include custom HTTP headers (unlike regular HTTP requests). This means the JWT Bearer token cannot be sent in the Authorization: Bearer token header for SignalR connections. The conventional workaround: pass the token as a query parameter (?access_token=eyJ...) and use the OnMessageReceived event to extract it. The SignalR JavaScript client does this automatically when configured — set the accessTokenFactory option (shown in the next lesson) and the client appends the token as a query parameter.
Tip: Use the strongly-typed Hub<IBlogHubClient> pattern instead of plain Hub. The plain hub uses Clients.All.SendAsync("ReceiveComment", comment) with string method names — a typo compiles but fails silently at runtime. The strongly-typed version uses Clients.All.ReceiveComment(comment) — compile-time checked, IntelliSense-supported, and refactoring-safe. Define IBlogHubClient as a shared interface that both the hub and the client TypeScript type definition mirror.
Warning: The _roomViewers ConcurrentDictionary is in-memory — it works correctly for a single-server deployment but is incorrect when multiple app instances are running (each instance has its own dictionary). With a load balancer and 3 app instances, joining a room on Instance A increments only Instance A’s counter; viewers on Instances B and C see incorrect counts. For multi-instance deployments, use Azure SignalR Service (covered in the final lesson) which manages group membership centrally.

Common Mistakes

Mistake 1 — JWT auth not configured for WebSocket connections (hub methods always unauthorized)

❌ Wrong — standard AddJwtBearer() without OnMessageReceived; WebSocket connections cannot pass Authorization header; all authenticated hub calls fail.

✅ Correct — configure OnMessageReceived to extract token from access_token query parameter for SignalR paths.

Mistake 2 — In-memory group state with multiple app instances (incorrect viewer counts)

❌ Wrong — ConcurrentDictionary for room counts; load balancer distributes connections across instances; each instance has partial state.

✅ Correct — Azure SignalR Service for production multi-instance deployments; in-memory only for single-instance or development.

🧠 Test Yourself

A user connects to BlogHub via WebSocket. Their JWT is valid. They call SendComment which has no [AllowAnonymous]. The hub class has [Authorize]. What is Context.UserIdentifier set to?