[Bug] Video-call hub WebSocket fails in prod — /hubs/video-call missing from JWT auth allowlist #97

Closed
opened 2026-05-10 23:42:18 +00:00 by spikerj · 1 comment
Owner

Symptom

In production (learn.spikersoft.com calling api.spikersoft.com), starting a video call from the menu picker immediately failed with the snack:

Couldn't invite that user. Please try again.

Browser console:

WebSocket connection to 'wss://api.spikersoft.com/hubs/video-call?access_token=...' failed.
Error: Failed to start the connection: Error: WebSocket failed to connect. The connection could
not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID
is not present on the server, or there is a proxy blocking WebSockets. If you have multiple
servers check that sticky sessions are enabled.

Repro

  1. Deploy backend with the new VideoCallHub mapped at /hubs/video-call (Phase 1 of the bulletproof-video plan).
  2. From any logged-in browser session, open Start a Video Call from the user menu.
  3. Pick another online user.
  4. WebSocket upgrade to /hubs/video-call?access_token=... closes immediately, picker shows the snack above.

Root cause

SpikerSoft.Api/Infrastructure/Authentication.cs -> IsSignalRHubPath(PathString) is the allowlist that tells the JWT bearer middleware to promote ?access_token=... (the only way SignalR's WebSocket transport can pass a bearer token — you can't set Authorization headers during a WebSocket upgrade) into context.Token.

The new /hubs/video-call endpoint was never added to that list. So at runtime:

  1. Browser opens wss://.../hubs/video-call?access_token=<jwt>.
  2. JWT middleware ignores the query param because the path isn't on the allowlist.
  3. WebSocket arrives at the hub anonymous.
  4. [Authorize] on VideoCallHub rejects it.
  5. Connection closes instantly. SignalR surfaces its standard misleading "endpoint may not be a SignalR endpoint / proxy blocking WebSockets" error — which is what tripped the picker snack.

Same latent bug existed for /hubs/pre-registration, /hubs/dashboard, /hubs/cluster — they were mapped in WebApplicationExtensions.ConfigureSignalRHubs but also missing from the allowlist.

Impact

  • Severity: P1 — feature is 100% broken in production for all users.
  • Users affected: anyone trying to start a video call.
  • Workaround: none.

How we'll prevent regressions

Left a comment in IsSignalRHubPath explaining that every authenticated hub MUST be added here and what the failure mode looks like, so the next person mapping a hub doesn't have to rediscover this from misleading SignalR errors.

## Symptom In production (`learn.spikersoft.com` calling `api.spikersoft.com`), starting a video call from the menu picker immediately failed with the snack: > Couldn't invite that user. Please try again. Browser console: ``` WebSocket connection to 'wss://api.spikersoft.com/hubs/video-call?access_token=...' failed. Error: Failed to start the connection: Error: WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled. ``` ## Repro 1. Deploy backend with the new `VideoCallHub` mapped at `/hubs/video-call` (Phase 1 of the bulletproof-video plan). 2. From any logged-in browser session, open `Start a Video Call` from the user menu. 3. Pick another online user. 4. WebSocket upgrade to `/hubs/video-call?access_token=...` closes immediately, picker shows the snack above. ## Root cause `SpikerSoft.Api/Infrastructure/Authentication.cs` -> `IsSignalRHubPath(PathString)` is the allowlist that tells the JWT bearer middleware to promote `?access_token=...` (the only way SignalR's WebSocket transport can pass a bearer token — you can't set `Authorization` headers during a WebSocket upgrade) into `context.Token`. The new `/hubs/video-call` endpoint was never added to that list. So at runtime: 1. Browser opens `wss://.../hubs/video-call?access_token=<jwt>`. 2. JWT middleware ignores the query param because the path isn't on the allowlist. 3. WebSocket arrives at the hub **anonymous**. 4. `[Authorize]` on `VideoCallHub` rejects it. 5. Connection closes instantly. SignalR surfaces its standard misleading "endpoint may not be a SignalR endpoint / proxy blocking WebSockets" error — which is what tripped the picker snack. Same latent bug existed for `/hubs/pre-registration`, `/hubs/dashboard`, `/hubs/cluster` — they were mapped in `WebApplicationExtensions.ConfigureSignalRHubs` but also missing from the allowlist. ## Impact - **Severity:** P1 — feature is 100% broken in production for all users. - **Users affected:** anyone trying to start a video call. - **Workaround:** none. ## How we'll prevent regressions Left a comment in `IsSignalRHubPath` explaining that **every authenticated hub MUST be added here** and what the failure mode looks like, so the next person mapping a hub doesn't have to rediscover this from misleading SignalR errors.
spikerj added the bug label 2026-05-10 23:42:44 +00:00
Author
Owner

Resolution

Fixed in SpikerSoft.Api/Infrastructure/Authentication.cs by adding /hubs/video-call (plus three other already-mapped hubs that were silently broken for the same reason: /hubs/pre-registration, /hubs/dashboard, /hubs/cluster) to the IsSignalRHubPath allowlist:

private static bool IsSignalRHubPath(PathString path)
{
    // SignalR's WebSocket transport can't set Authorization headers, so
    // the client passes the bearer token via `?access_token=...` and we
    // promote it to `context.Token` here. Every authenticated hub MUST
    // be listed below — if it isn't, the WebSocket upgrade comes in
    // anonymous, the hub's [Authorize] kicks it, and the client sees
    // a misleading "WebSocket failed to connect / endpoint may not be
    // a SignalR endpoint" error (which is exactly what the
    // bulletproof-video Phase 1 hub hit in production).
    return path.StartsWithSegments("/hubs/chat") ||
           path.StartsWithSegments("/hubs/game") ||
           path.StartsWithSegments("/hubs/notifications") ||
           path.StartsWithSegments("/hubs/calendar-notifications") ||
           path.StartsWithSegments("/hubs/quiz") ||
           path.StartsWithSegments("/hubs/dungeon-crawler") ||
           path.StartsWithSegments("/hubs/chess") ||
           path.StartsWithSegments("/hubs/video-call") ||
           path.StartsWithSegments("/hubs/pre-registration") ||
           path.StartsWithSegments("/hubs/dashboard") ||
           path.StartsWithSegments("/hubs/cluster") ||
           path.StartsWithSegments("/codeExecutionHub");
}

No frontend changes required. After the next backend deploy, the WebSocket upgrade carries the bearer token, [Authorize] on VideoCallHub is satisfied, and the picker's invite goes through.

Verification

  • SpikerSoft.Tests.Unit/SignalR/VideoCallHubTests.cs already covers the auth-required paths (anonymous invite is rejected, etc.) so any future regression where the hub becomes anonymous-callable would fail there.
  • Manual: wss://api.spikersoft.com/hubs/video-call?access_token=<jwt> should now complete its upgrade and the hub's OnConnectedAsync should log VideoCallHub: user <uid> connected.

Closing as fixed.

## Resolution Fixed in `SpikerSoft.Api/Infrastructure/Authentication.cs` by adding `/hubs/video-call` (plus three other already-mapped hubs that were silently broken for the same reason: `/hubs/pre-registration`, `/hubs/dashboard`, `/hubs/cluster`) to the `IsSignalRHubPath` allowlist: ```csharp private static bool IsSignalRHubPath(PathString path) { // SignalR's WebSocket transport can't set Authorization headers, so // the client passes the bearer token via `?access_token=...` and we // promote it to `context.Token` here. Every authenticated hub MUST // be listed below — if it isn't, the WebSocket upgrade comes in // anonymous, the hub's [Authorize] kicks it, and the client sees // a misleading "WebSocket failed to connect / endpoint may not be // a SignalR endpoint" error (which is exactly what the // bulletproof-video Phase 1 hub hit in production). return path.StartsWithSegments("/hubs/chat") || path.StartsWithSegments("/hubs/game") || path.StartsWithSegments("/hubs/notifications") || path.StartsWithSegments("/hubs/calendar-notifications") || path.StartsWithSegments("/hubs/quiz") || path.StartsWithSegments("/hubs/dungeon-crawler") || path.StartsWithSegments("/hubs/chess") || path.StartsWithSegments("/hubs/video-call") || path.StartsWithSegments("/hubs/pre-registration") || path.StartsWithSegments("/hubs/dashboard") || path.StartsWithSegments("/hubs/cluster") || path.StartsWithSegments("/codeExecutionHub"); } ``` No frontend changes required. After the next backend deploy, the WebSocket upgrade carries the bearer token, `[Authorize]` on `VideoCallHub` is satisfied, and the picker's invite goes through. ### Verification - `SpikerSoft.Tests.Unit/SignalR/VideoCallHubTests.cs` already covers the auth-required paths (anonymous invite is rejected, etc.) so any future regression where the hub becomes anonymous-callable would fail there. - Manual: `wss://api.spikersoft.com/hubs/video-call?access_token=<jwt>` should now complete its upgrade and the hub's `OnConnectedAsync` should log `VideoCallHub: user <uid> connected`. Closing as fixed.
Sign in to join this conversation.