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.
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.
OnDisconnectedAsync mirrors the multi-group teardown.
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.
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
Two browsers logged in as different Keycloak users (caller A, callee B).
A opens menu → "Start video call" → selects B.
Before fix: A sees "Calling B / Waiting…" indefinitely; B never sees an incoming-call dialog. No backend errors.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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— setsUser.Id = Context.User.FindFirst("sid").Value(Keycloak session id) on every connection. The frontendchatService.users()therefore exposes each user keyed bysid.VideoCallHub—GetCurrentUserId()returnssub(Keycloak user UUID) and on connect adds the connection tovideo-user-{sub}.When the picker calls
signaling.inviteUser(user.id, ...)it passes thesid, so backendInviteUserdoesClients.Group("video-user-{sid}")— a group no one is in. The callee's connection lives invideo-user-{sub}only, soIncomingCallnever arrives.A secondary follow-on bug:
AcceptInviteandDeclineInvitevalidatedinvite.CalleeId == GetCurrentUserId()(sidvssub) 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:OnConnectedAsyncnow subscribes the connection tovideo-user-{id}for every non-empty identity claim variant on the connection (sub,sid,nameidentifier,user_id,Identity.Name) via a newGetAllUserIdentifiers()helper.OnDisconnectedAsyncmirrors the multi-group teardown.AcceptInvite/DeclineInvitenow verifyGetAllUserIdentifiers().Contains(invite.CalleeId)instead of strict equality withGetCurrentUserId(), so an invite addressed by any claim the user owns is honored. Internal state continues to canonicalize onsubfor_userRooms/OtherUserconsistency.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 bothvideo-user-{sub}andvideo-user-{sid}are joined.AcceptInvite_AcceptsWhenAddressedByAnyIdentityClaim— caller invites bysid, callee withsub+sidaccepts; the"This invite is not for you"HubException must NOT be thrown.Both tests pass.
Repro