6.5 KiB
6.5 KiB
WebSocket Subscription Rejection Fix
Issue
Error: "GatewayChannel is transmitting the subscription rejection"
Root Cause
The GatewayChannel#subscribed method was attempting to re-authenticate the gateway using params[:api_key_digest], but this caused two problems:
- Double Authentication: The gateway was already authenticated at the connection level in
ApplicationCable::Connection#connect - Missing Parameter: The channel was expecting
api_key_digestas a channel parameter, but it was never being passed - Wrong Layer: Authentication should happen at the connection level (before subscription), not at the channel subscription level
Authentication Flow
Correct Flow (After Fix)
1. Client connects to WebSocket
URL: ws://host/cable?api_key=gw_live_...
2. ApplicationCable::Connection#connect
- Extracts api_key from query params
- Hashes it with SHA256
- Finds gateway by api_key_digest
- Sets current_gateway
- Connection accepted ✅
3. Client subscribes to GatewayChannel
Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}
4. GatewayChannel#subscribed
- Uses current_gateway (already authenticated)
- Streams to gateway-specific channel
- Updates heartbeat
- Subscription accepted ✅
Previous Flow (Before Fix)
1. Client connects to WebSocket
URL: ws://host/cable?api_key=gw_live_...
2. ApplicationCable::Connection#connect
- Authenticates gateway
- Connection accepted ✅
3. Client subscribes to GatewayChannel
Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}
4. GatewayChannel#subscribed (BROKEN)
- Looks for params[:api_key_digest] ❌ (doesn't exist)
- Gateway lookup fails ❌
- Subscription rejected ❌
The Fix
Before (Broken)
class GatewayChannel < ApplicationCable::Channel
def subscribed
# Authenticate gateway using API key from params
api_key_digest = params[:api_key_digest] # ❌ This was never passed!
unless api_key_digest
reject # ❌ Always rejected here
return
end
@gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)
unless @gateway
reject
return
end
stream_for @gateway
@gateway.heartbeat!
Rails.logger.info("Gateway #{@gateway.device_id} connected via WebSocket")
end
end
After (Fixed)
class GatewayChannel < ApplicationCable::Channel
def subscribed
# Gateway is already authenticated at the connection level
# current_gateway is set by ApplicationCable::Connection
@gateway = current_gateway # ✅ Use already-authenticated gateway
unless @gateway
reject
return
end
# Subscribe to gateway-specific channel
stream_for @gateway
# Update gateway status
@gateway.heartbeat!
Rails.logger.info("Gateway #{@gateway.device_id} subscribed to GatewayChannel")
end
end
Key Changes
- Removed
params[:api_key_digest]lookup (it was never passed) - Use
current_gatewaywhich is set by the connection authentication - Simplified authentication - it only happens once at the connection level
- Removed redundant authentication check
Connection Authentication
The connection-level authentication in ApplicationCable::Connection is the correct place for this:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_gateway
def connect
self.current_gateway = find_verified_gateway
logger.info "Gateway #{current_gateway.device_id} connected"
end
private
def find_verified_gateway
# Get API key from request params (query string)
api_key = request.params[:api_key]
if api_key.blank?
reject_unauthorized_connection
end
# Hash the API key and find gateway
api_key_digest = Digest::SHA256.hexdigest(api_key)
gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)
if gateway
gateway
else
reject_unauthorized_connection
end
end
end
end
Testing the Fix
Test with wscat
wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here"
After connection, subscribe to the channel:
{"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}
Expected Response:
{
"identifier": "{\"channel\":\"GatewayChannel\"}",
"type": "confirm_subscription"
}
Test from Android
val wsUrl = "ws://192.168.1.100:3000/cable?api_key=$apiKey"
val request = Request.Builder().url(wsUrl).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Connection successful
// Subscribe to channel
val subscribeMsg = JSONObject().apply {
put("command", "subscribe")
put("identifier", """{"channel":"GatewayChannel"}""")
}
webSocket.send(subscribeMsg.toString())
}
override fun onMessage(webSocket: WebSocket, text: String) {
val json = JSONObject(text)
if (json.optString("type") == "confirm_subscription") {
// Subscription successful! ✅
Log.d(TAG, "Subscribed to GatewayChannel")
}
}
})
Why This Matters
Before Fix
- ❌ All subscription attempts were rejected
- ❌ Gateways couldn't receive SMS send commands
- ❌ Real-time communication was broken
- ❌ Messages stuck in "pending" state
After Fix
- ✅ Subscriptions work correctly
- ✅ Gateways receive SMS send commands in real-time
- ✅ Bidirectional communication enabled
- ✅ Messages sent immediately to online gateways
Related Files
app/channels/application_cable/connection.rb- Connection authentication (unchanged, already correct)app/channels/gateway_channel.rb- Channel subscription (FIXED)CABLE_DOCUMENTATION.md- WebSocket integration guide (already correct)API_DOCUMENTATION.md- Full API docs (already correct)
Summary
The fix removes redundant authentication from the channel subscription layer and properly uses the current_gateway that was already authenticated at the connection level. This follows Action Cable best practices where:
- Connection authentication happens when WebSocket connects
- Channel subscription happens after connection is authenticated
- No re-authentication needed in channels - use
current_gatewayor other identifiers set at connection level
WebSocket connections from Android devices should now work correctly! 🚀