[Bug] Video-call invite never reaches callee — VideoCallHub uses sub claim but picker addresses callee by sid from ChatHub #99

Closed
opened 2026-05-11 01:31:20 +00:00 by spikerj · 0 comments
Owner

Symptom

Caller opens the video-call picker, selects another user (e.g. "Bobby Spiker"), the outgoing-call dialog renders "Calling Bobby Spiker / Waiting for them to pick up" and counts up indefinitely. The callee's browser never shows the incoming-call ringing dialog, so the call can never be accepted. No backend exception is logged because the SignalR hub publishes to an empty group and silently drops the message.

Root cause

Two SignalR hubs identify the same physical user with different Keycloak claims:

  • ChatHub — sets User.Id = Context.User.FindFirst("sid").Value (Keycloak session id) on every connection. The frontend chatService.users() therefore exposes each user keyed by sid.
  • VideoCallHubGetCurrentUserId() returns sub (Keycloak user UUID) and on connect adds the connection to video-user-{sub}.

When the picker calls signaling.inviteUser(user.id, ...) it passes the sid, so backend InviteUser does Clients.Group("video-user-{sid}") — a group no one is in. The callee's connection lives in video-user-{sub} only, so IncomingCall never arrives.

A secondary follow-on bug: AcceptInvite and DeclineInvite validated invite.CalleeId == GetCurrentUserId() (sid vs sub) and would have thrown "This invite is not for you" even if the IncomingCall had reached the callee somehow.

Fix

spikersoft-backend/SpikerSoft.Contracts.SignalR/VideoCallHub.cs:

  1. OnConnectedAsync now subscribes the connection to video-user-{id} for every non-empty identity claim variant on the connection (sub, sid, nameidentifier, user_id, Identity.Name) via a new GetAllUserIdentifiers() helper.
  2. OnDisconnectedAsync mirrors the multi-group teardown.
  3. AcceptInvite / DeclineInvite now verify GetAllUserIdentifiers().Contains(invite.CalleeId) instead of strict equality with GetCurrentUserId(), so an invite addressed by any claim the user owns is honored. Internal state continues to canonicalize on sub for _userRooms / OtherUser consistency.

The fix is intentionally backend-only: the picker contract ("send whatever id ChatHub gave you") and ChatHub's existing sid-based identity continue to work unchanged. Once the redeploy is live, all in-flight pages will start working without a frontend rebuild.

Tests

spikersoft-backend/SpikerSoft.Tests.Unit/SignalR/VideoCallHubTests.cs:

  • OnConnectedAsync_RegistersUserGroupForEveryIdentityClaim — asserts both video-user-{sub} and video-user-{sid} are joined.
  • AcceptInvite_AcceptsWhenAddressedByAnyIdentityClaim — caller invites by sid, callee with sub + sid accepts; the "This invite is not for you" HubException must NOT be thrown.

Both tests pass.

Repro

  1. Two browsers logged in as different Keycloak users (caller A, callee B).
  2. A opens menu → "Start video call" → selects B.
  3. Before fix: A sees "Calling B / Waiting…" indefinitely; B never sees an incoming-call dialog. No backend errors.
  4. After fix: B's browser pops the incoming-call ringing dialog and the call can be accepted/declined.
## Symptom Caller opens the video-call picker, selects another user (e.g. "Bobby Spiker"), the outgoing-call dialog renders "Calling Bobby Spiker / Waiting for them to pick up" and counts up indefinitely. The callee's browser **never shows the incoming-call ringing dialog**, so the call can never be accepted. No backend exception is logged because the SignalR hub publishes to an empty group and silently drops the message. ## Root cause Two SignalR hubs identify the same physical user with **different Keycloak claims**: - **`ChatHub`** — sets `User.Id = Context.User.FindFirst("sid").Value` (Keycloak session id) on every connection. The frontend `chatService.users()` therefore exposes each user keyed by `sid`. - **`VideoCallHub`** — `GetCurrentUserId()` returns `sub` (Keycloak user UUID) and on connect adds the connection to `video-user-{sub}`. When the picker calls `signaling.inviteUser(user.id, ...)` it passes the `sid`, so backend `InviteUser` does `Clients.Group("video-user-{sid}")` — a group **no one is in**. The callee's connection lives in `video-user-{sub}` only, so `IncomingCall` never arrives. A secondary follow-on bug: `AcceptInvite` and `DeclineInvite` validated `invite.CalleeId == GetCurrentUserId()` (`sid` vs `sub`) and would have thrown "This invite is not for you" even if the IncomingCall had reached the callee somehow. ## Fix `spikersoft-backend/SpikerSoft.Contracts.SignalR/VideoCallHub.cs`: 1. `OnConnectedAsync` now subscribes the connection to `video-user-{id}` for **every** non-empty identity claim variant on the connection (`sub`, `sid`, `nameidentifier`, `user_id`, `Identity.Name`) via a new `GetAllUserIdentifiers()` helper. 2. `OnDisconnectedAsync` mirrors the multi-group teardown. 3. `AcceptInvite` / `DeclineInvite` now verify `GetAllUserIdentifiers().Contains(invite.CalleeId)` instead of strict equality with `GetCurrentUserId()`, so an invite addressed by *any* claim the user owns is honored. Internal state continues to canonicalize on `sub` for `_userRooms` / `OtherUser` consistency. The fix is intentionally backend-only: the picker contract ("send whatever id ChatHub gave you") and ChatHub's existing `sid`-based identity continue to work unchanged. Once the redeploy is live, all in-flight pages will start working without a frontend rebuild. ## Tests `spikersoft-backend/SpikerSoft.Tests.Unit/SignalR/VideoCallHubTests.cs`: - `OnConnectedAsync_RegistersUserGroupForEveryIdentityClaim` — asserts both `video-user-{sub}` and `video-user-{sid}` are joined. - `AcceptInvite_AcceptsWhenAddressedByAnyIdentityClaim` — caller invites by `sid`, callee with `sub` + `sid` accepts; the `"This invite is not for you"` HubException must NOT be thrown. Both tests pass. ## Repro 1. Two browsers logged in as different Keycloak users (caller A, callee B). 2. A opens menu → "Start video call" → selects B. 3. **Before fix**: A sees "Calling B / Waiting…" indefinitely; B never sees an incoming-call dialog. No backend errors. 4. **After fix**: B's browser pops the incoming-call ringing dialog and the call can be accepted/declined.
spikerj added the bug label 2026-05-11 01:31:32 +00:00
Sign in to join this conversation.