Secure socket broker with token auth and rooms
Build and Push Docker Images / build-and-push (push) Successful in 1m27s

This commit is contained in:
Bram committed 2026-10-27 09:35:09 +00:00
1 parent ebd424e0be
commit 3dec007000
9 files changed
+1012 -716

No files matched your search

+121 -456
View File
@@ -1,514 +1,179 @@
# Socket Broker
A WebSocket broker application that handles topic-based messaging between frontend clients and backend services with API key authentication.
A Socket.IO broker with topic-based messaging for ComedyKit. Backend services publish over an HTTP API protected by an API key; browsers and mobile clients authenticate with short-lived, topic-scoped tokens issued by Laravel.
## Features
- **WebSocket Server** (Port 3000): Handles client connections and topic subscriptions
- **HTTP API** (Port 8080): Allows backend services to publish messages to topics
- **API Key Authentication**: Secure access control for publishing messages
- **Topic-based messaging**: Clients subscribe to topics and receive messages when published
- **Real-time communication**: Instant message delivery to all subscribed clients
- **Health monitoring**: Built-in health checks and status endpoints
- Socket.IO server for real-time client subscriptions.
- HTTP API for backend publishing.
- API-key authentication for publishers.
- HMAC-SHA256 client tokens for connection and topic authorization.
- Room-based topic delivery (no in-memory subscription maps).
- Protected topic introspection and public health check.
- CORS allowlist, subscription limits, and subscribe rate limiting.
## Architecture
```
Frontend Clients ←→ WebSocket Server (Port 3000)
↕
Backend Services ←→ HTTP API (Port 8080) [🔐 API Key Required]
Frontend / mobile clients
│ Socket.IO (connection token + topic token)
▼
Socket broker ── rooms: user/{uuid}/notifications
▲ event/{slug}/ticket_statistics
│ POST /publish (API key)
Laravel backend
```
## Security
## Configuration
**⚠️ IMPORTANT**: The HTTP API endpoints that publish messages require a valid API key for authentication. This ensures that only authorized backend services can send messages to your WebSocket clients.
Copy `env.example` to `.env`:
### API Key Configuration
| Variable | Default | Description |
| --- | --- | --- |
| `WS_PORT` | `3000` | Socket.IO / HTTP port |
| `API_PORT` | `WS_PORT` | Optional second HTTP API port |
| `API_KEY` | `default-api-key-change-me` | Publisher API key; never expose to clients |
| `SOCKET_TOKEN_SECRET` | — | Shared HMAC secret; must match Laravel `SOCKET_TOKEN_SECRET` |
| `AUTH_MODE` | `required` | `off`, `optional` (rollout only), or `required` |
| `ALLOWED_ORIGINS` | `https://*.comedykit.be,...` | Comma-separated origins; supports `*.domain.tld` |
| `MAX_TOPICS_PER_SOCKET` | `20` | Subscription cap per connection |
| `SUBSCRIBE_RATE_LIMIT` | `30` | Subscribe attempts per 10 seconds per connection |
Set the `API_KEY` environment variable to secure your application:
`AUTH_MODE=required` refuses to start without `SOCKET_TOKEN_SECRET`.
```bash
# Set a strong, random API key
export API_KEY="your-secure-api-key-here"
### Rollout
# Or use in docker-compose
API_KEY=your-secure-api-key-here docker-compose up
1. Deploy with `AUTH_MODE=optional` so legacy clients still work.
2. Deploy Laravel with `/notifications/socket-token` and the new publisher job.
3. Deploy the updated web/mobile clients.
4. Switch to `AUTH_MODE=required`.
## Client authentication
Laravel issues two token types:
```json
// Connection token
{ "v": 1, "sub": "<user uuid>", "typ": "connect", "iat": 0, "exp": 0, "jti": "..." }
// Topic token
{ "v": 1, "sub": "<user uuid>", "typ": "topic", "topic": "event/slug/ticket_statistics", "iat": 0, "exp": 0, "jti": "..." }
```
### Authentication Methods
Tokens are encoded as:
The API accepts the API key in three ways:
1. **Authorization Header** (Recommended):
```bash
Authorization: Bearer your-api-key-here
```
2. **X-API-Key Header**:
```bash
X-API-Key: your-api-key-here
```
3. **Query Parameter**:
```bash
POST /publish?api_key=your-api-key-here
```
## Quick Start
### Using Docker
```bash
# Build the image
docker build -t socket-broker .
# Run the container with API key
docker run -d \
--name socket-broker \
-p 3000:3000 \
-p 8080:8080 \
-e API_KEY="your-secure-api-key" \
socket-broker
```
base64url(JSON payload) + "." + base64url(HMAC-SHA256(payload, SOCKET_TOKEN_SECRET))
```
### Using Docker Compose
```bash
# Copy environment file
cp env.example .env
# Edit .env and set your API key
nano .env
# Start the service
docker-compose up -d
```
### Using Node.js directly
```bash
# Set API key
export API_KEY="your-secure-api-key"
# Install dependencies
npm install
# Start the application
npm start
```
## Environment Variables
| Variable | Default | Description |
| ---------- | --------------------------- | ---------------------------------------- |
| `WS_PORT` | `3000` | WebSocket server port |
| `API_PORT` | `8080` | HTTP API server port |
| `API_KEY` | `default-api-key-change-me` | **REQUIRED**: API key for authentication |
## WebSocket API (Port 3000)
### Connection
Connect to `ws://localhost:3000` using Socket.IO client.
### Events
#### Subscribe to a topic
Clients connect with the connection token:
```javascript
socket.emit("subscribe", "user-notifications");
socket.on("subscribed", (data) => {
const socket = io("https://bram-sockets.comedykit.be", {
auth: { token: connectionToken },
});
```
A user may always subscribe to `user/{their-own-id}/notifications`. Any other topic requires a topic token whose `sub` matches the connection subject and whose `topic` matches the subscription exactly.
## WebSocket API
### Subscribe
```javascript
socket.emit("subscribe", "user/<uuid>/notifications");
// Or, for a topic-scoped token:
socket.emit("subscribe", {
topic: "event/my-event/ticket_statistics",
token: topicToken,
});
socket.on("subscribed", data => {
console.log(`Subscribed to ${data.topic}`);
});
```
#### Unsubscribe from a topic
### Unsubscribe
```javascript
socket.emit("unsubscribe", "user-notifications");
socket.on("unsubscribed", (data) => {
socket.emit("unsubscribe", "user/<uuid>/notifications");
socket.on("unsubscribed", data => {
console.log(`Unsubscribed from ${data.topic}`);
});
```
#### Receive messages
### Receive messages
```javascript
socket.on("message", (data) => {
console.log(`Message from topic ${data.topic}:`, data.message);
console.log("Data:", data.data);
console.log("Timestamp:", data.timestamp);
socket.on("message", data => {
console.log(data.topic, data.message, data.data, data.timestamp);
});
```
#### Error handling
### Errors
```javascript
socket.on("error", (error) => {
console.error("Socket error:", error.message);
socket.on("error", error => {
// INVALID_TOPIC | FORBIDDEN_TOPIC | RATE_LIMITED | TOPIC_LIMIT
console.error(error.code, error.message);
});
```
## HTTP API (Port 8080)
## HTTP API
### Public Endpoints (No Authentication Required)
### Public
#### Health Check
- `GET /health` — status, connection count, auth mode. Does not expose topic names.
### Protected (API key)
- `GET /topics`
- `GET /topics/:topic`
- `POST /publish`
- `POST /publish/:topic`
API key is accepted as `Authorization: Bearer <key>`, `x-api-key`, or `api_key` query parameter.
#### Publish
```bash
GET /health
```
Response:
```json
{
"status": "healthy",
"connections": 5,
"topics": 3,
"timestamp": "2024-01-01T12:00:00.000Z",
"apiKeyConfigured": true
}
```
#### List Topics
```bash
GET /topics
```
Response:
```json
{
"topics": [
{
"topic": "user-notifications",
"subscribers": 2
},
{
"topic": "system-alerts",
"subscribers": 1
}
]
}
```
#### Get Topic Info
```bash
GET /topics/{topic}
```
Response:
```json
{
"topic": "user-notifications",
"subscribers": 2
}
```
### Protected Endpoints (API Key Required)
#### Response Codes
- **200 OK**: Message published successfully to subscribers
- **204 No Content**: Message published successfully but no subscribers for the topic
- **400 Bad Request**: Invalid request (missing topic, message, or data)
- **401 Unauthorized**: Invalid or missing API key
- **500 Internal Server Error**: Server error
#### Publish Message
```bash
POST /publish
Authorization: Bearer your-api-key-here
Content-Type: application/json
{
"topic": "user-notifications",
"message": "New message received",
"data": { "userId": 123, "type": "email" }
}
```
Alternative endpoint:
```bash
POST /publish/{topic}
Authorization: Bearer your-api-key-here
Content-Type: application/json
{
"message": "New message received",
"data": { "userId": 123, "type": "email" }
}
```
Response:
```json
{
"success": true,
"message": "Message published to topic 'user-notifications'",
"subscribers": 2,
"payload": {
"topic": "user-notifications",
"message": "New message received",
"data": { "userId": 123, "type": "email" },
"timestamp": "2024-01-01T12:00:00.000Z"
}
}
```
**Note**: If there are no subscribers for the topic, the API returns `204 No Content` instead of an error. This indicates that the message was published successfully, but there were no recipients to receive it.
### Authentication Errors
If you don't provide a valid API key, you'll get:
```json
{
"error": "Unauthorized",
"message": "API key is required. Use Authorization header or x-api-key header or api_key query parameter."
}
```
## Frontend Example (JavaScript)
```html
<!DOCTYPE html>
<html>
<head>
<title>Socket Broker Client</title>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
</head>
<body>
<h1>Socket Broker Client</h1>
<div id="status">Connecting...</div>
<div id="messages"></div>
<script>
const socket = io("http://localhost:3000");
socket.on("connect", () => {
document.getElementById("status").textContent = "Connected";
// Subscribe to a topic
socket.emit("subscribe", "user-notifications");
});
socket.on("subscribed", (data) => {
console.log("Subscribed:", data);
});
socket.on("message", (data) => {
const messagesDiv = document.getElementById("messages");
const messageElement = document.createElement("div");
messageElement.innerHTML = `
<strong>${data.topic}:</strong> ${data.message}
<br><small>${data.timestamp}</small>
`;
messagesDiv.appendChild(messageElement);
});
socket.on("disconnect", () => {
document.getElementById("status").textContent = "Disconnected";
});
</script>
</body>
</html>
```
## Backend Example (Python)
```python
import requests
import json
import os
# Get API key from environment variable
API_KEY = os.getenv('API_KEY', 'your-api-key-here')
def publish_message(topic, message, data=None):
url = "http://localhost:8080/publish"
headers = {
'Authorization': f'Bearer {API_KEY}',
'Content-Type': 'application/json'
}
payload = {
"topic": topic,
"message": message,
"data": data
}
response = requests.post(url, json=payload, headers=headers)
return response.json()
# Example usage
result = publish_message(
topic="user-notifications",
message="New email received",
data={"userId": 123, "emailId": "abc123"}
)
print(result)
```
## Backend Example (Node.js)
```javascript
const axios = require("axios");
const API_KEY = process.env.API_KEY || "your-api-key-here";
async function publishMessage(topic, message, data = null) {
try {
const response = await axios.post(
"http://localhost:8080/publish",
{
topic,
message,
data,
},
{
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
}
);
return response.data;
} catch (error) {
console.error(
"Error publishing message:",
error.response?.data || error.message
);
throw error;
}
}
// Example usage
publishMessage("user-notifications", "New notification", { userId: 123 })
.then((result) => console.log("Message published:", result))
.catch((error) => console.error("Failed to publish:", error));
```
## Docker Compose Example
```yaml
version: "3.8"
services:
socket-broker:
build: .
ports:
- "3000:3000"
- "8080:8080"
environment:
- NODE_ENV=production
- WS_PORT=3000
- API_PORT=8080
- API_KEY=${API_KEY:-default-api-key-change-me}
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:8080/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
env_file:
- .env
```
## Monitoring
The application provides several monitoring endpoints:
- `/health` - Overall health status and API key configuration status
- `/topics` - List of active topics and subscriber counts
- `/topics/{topic}` - Specific topic information
## Security Best Practices
1. **Generate Strong API Keys**: Use cryptographically secure random strings
```bash
# Generate a secure API key
openssl rand -base64 32
```
2. **Environment Variables**: Never hardcode API keys in your code
```bash
# Good
export API_KEY="$(openssl rand -base64 32)"
# Bad
export API_KEY="my-secret-key-123"
```
3. **Rotate Keys Regularly**: Change your API keys periodically
4. **Limit Access**: Only share API keys with trusted backend services
5. **HTTPS in Production**: Use HTTPS/WSS in production environments
6. **Network Security**: Consider using internal networks for backend communication
## Troubleshooting
### Common Issues
1. **401 Unauthorized**: Check that you're providing the correct API key
2. **Port conflicts**: Ensure ports 3000 and 8080 are available
3. **CORS errors**: Check that the frontend origin is allowed
4. **Connection refused**: Verify the container is running and ports are exposed
### Testing Authentication
Test your API key authentication:
```bash
# Test without API key (should fail with 401)
curl -X POST http://localhost:8080/publish \
curl -X POST http://localhost:3000/publish \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"topic":"test","message":"hello"}'
# Test with correct API key but no subscribers (should succeed with 204)
curl -X POST http://localhost:8080/publish \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"topic":"test","message":"hello"}'
# Test with correct API key and subscribers (should succeed with 200)
curl -X POST http://localhost:8080/publish \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"topic":"test","message":"hello"}'
-d '{"topic":"user/<uuid>/notifications","message":"{\"id\":\"...\"}"}'
```
### Logs
Response:
Check application logs for detailed information:
- `200` with `{ success, message, subscribers, payload }`
- `204` when the topic has no subscribers
- `400` invalid request
- `401` invalid or missing API key
## Development
```bash
docker logs socket-broker
npm install
npm test
AUTH_MODE=optional API_KEY=dev-key SOCKET_TOKEN_SECRET=dev-secret npm start
```
### Health Check
Verify the application is healthy and API key is configured:
## Docker
```bash
curl http://localhost:8080/health
cp env.example .env
# set API_KEY, SOCKET_TOKEN_SECRET, AUTH_MODE, ALLOWED_ORIGINS
docker compose up -d
```
The Docker image runs as a non-root user and exposes port 3000. If `API_PORT` differs from `WS_PORT`, a second HTTP server is started on that port.
## Security notes
- `API_KEY` is publisher-only. Never ship it to clients.
- `SOCKET_TOKEN_SECRET` must be at least 32 random bytes and identical in Laravel and the broker.
- Tokens are short-lived. The broker verifies them when a subscription is created; clients re-fetch tokens on reconnect.
- Use HTTPS/WSS in production.
- Rotate secrets by coordinating Laravel and broker deployments; outstanding tokens become invalid.
- `AUTH_MODE=optional` or `off` is for local development or the rollout window only.