Integrating ActiveMQ Message Streams with a WebAuthn-Secured Frontend Component


The initial requirement was deceptive in its simplicity: display a real-time stream of system audit events on an internal administrative dashboard. The existing infrastructure published these events to an AUDIT.LOG topic on an ActiveMQ cluster. The obvious but flawed approach—frequent AJAX polling—was immediately discarded due to its inefficiency and the latency it would introduce. A push-based model was necessary. The complexity emerged from the non-negotiable security and architectural constraints. First, authentication had to be passwordless, mandating the use of WebAuthn for its phishing resistance. Second, the front-end component needed to be a self-contained, embeddable widget, meaning its styling could not conflict with any host application. This led to a technology stack that, on the surface, seems disparate: ActiveMQ for the event source, WebAuthn for security, and CSS Modules for UI encapsulation.

The core of the problem became architecting a secure bridge between the message broker and the authenticated user’s browser. This bridge, a WebSocket gateway, would be responsible for subscribing to ActiveMQ, validating a user’s session derived from a successful WebAuthn ceremony, and then streaming messages exclusively to that user’s connection.

sequenceDiagram
    participant User as User's Browser
    participant Gateway as Node.js WebSocket Gateway
    participant IdP as Identity Provider (WebAuthn)
    participant ActiveMQ

    User->>+Gateway: HTTP POST /login/start (username)
    Gateway->>+IdP: Generate WebAuthn Challenge
    IdP-->>-Gateway: Challenge
    Gateway-->>-User: Returns Challenge

    User->>User: User interacts with authenticator (Touch ID, YubiKey)
    User->>+Gateway: HTTP POST /login/finish (signed challenge)
    Gateway->>+IdP: Verify Signature
    IdP-->>-Gateway: Verification OK
    Gateway->>Gateway: Create secure session
    Gateway-->>-User: Return Session Token

    User->>Gateway: WebSocket Upgrade Request (with Session Token)
    Gateway->>Gateway: Validate Session Token
    alt Session Valid
        Gateway-->>User: 101 Switching Protocols
        Gateway->>+ActiveMQ: STOMP SUBSCRIBE to AUDIT.LOG
        Note over Gateway,User: WebSocket Connection Established
    else Session Invalid
        Gateway-->>User: HTTP 401 Unauthorized
    end

    loop Real-time Events
        ActiveMQ-->>Gateway: MESSAGE (Audit Event)
        Gateway->>User: WebSocket Frame (Event Payload)
    end

The sequence above defines the system’s flow. A standard HTTP-based WebAuthn login establishes a session. This session is then used to authorize the creation of a persistent WebSocket connection, which becomes the conduit for messages consumed from ActiveMQ.

Backend Implementation: The Secure Gateway

The gateway is a Node.js application using Express for HTTP endpoints, the ws library for WebSocket handling, and stompjs to communicate with ActiveMQ. The security aspect hinges on the simplewebauthn library for managing the FIDO2/WebAuthn ceremony.

A critical design decision was to completely decouple the HTTP session from the WebSocket connection lifecycle after the initial handshake. The HTTP endpoints handle only the WebAuthn registration and authentication flows, creating a short-lived token that is used once to upgrade the connection to a WebSocket.

1. Project Setup and Configuration

A production-grade setup requires robust configuration management, not hardcoded values.

config/default.json:

{
  "server": {
    "port": 8080,
    "rpID": "localhost",
    "rpName": "Secure Audit Console",
    "origin": "http://localhost:3000"
  },
  "activemq": {
    "host": "127.0.0.1",
    "port": 61613,
    "user": "admin",
    "pass": "admin",
    "topic": "/topic/AUDIT.LOG"
  },
  "logging": {
    "level": "info"
  }
}

2. WebAuthn Authentication Endpoints

The core logic resides in establishing user identity. We maintain a simplistic in-memory database for this example, but in a real-world project, this would be a persistent user store like PostgreSQL or Redis.

src/server.js:

const express = require('express');
const http = require('http');
const config = require('config');
const {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
const { isoBase64URL, isoUint8Array } = require('@simplewebauthn/server/helpers');
const crypto = require('crypto');
const logger = require('./logger'); // A simple Winston logger setup

const app = express();
app.use(express.json());

// In-memory store for users and their authenticators.
// DO NOT use this in production. Use a proper database.
const userStore = {};

const rpID = config.get('server.rpID');
const rpName = config.get('server.rpName');
const origin = config.get('server.origin');

// Registration flow
app.post('/register/start', (req, res) => {
  const { username } = req.body;
  if (!username) {
    return res.status(400).json({ error: 'Username is required' });
  }
  if (userStore[username]) {
    return res.status(409).json({ error: 'User already exists' });
  }

  const user = {
    id: isoBase64URL.fromBuffer(crypto.randomBytes(16)),
    username,
    authenticators: [],
  };
  userStore[username] = user;

  const options = generateRegistrationOptions({
    rpName,
    rpID,
    userID: user.id,
    userName: user.username,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
  });

  // Store the challenge temporarily
  user.currentChallenge = options.challenge;
  logger.info(`Generated registration challenge for ${username}`);
  res.json(options);
});

app.post('/register/finish', async (req, res) => {
  const { username, response } = req.body;
  const user = userStore[username];

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  try {
    const verification = await verifyRegistrationResponse({
      response,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });

    if (verification.verified) {
      user.authenticators.push(verification.registrationInfo);
      user.currentChallenge = null; // Clear challenge
      logger.info(`Successfully registered authenticator for ${username}`);
      res.json({ success: true });
    } else {
      res.status(400).json({ error: 'Verification failed' });
    }
  } catch (error) {
    logger.error(`Registration verification error for ${username}: ${error.message}`);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// Authentication flow
app.post('/login/start', (req, res) => {
    const { username } = req.body;
    const user = userStore[username];

    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    const options = generateAuthenticationOptions({
        rpID,
        allowCredentials: user.authenticators.map(auth => ({
            id: auth.credentialID,
            type: 'public-key',
        })),
    });

    user.currentChallenge = options.challenge;
    logger.info(`Generated authentication challenge for ${username}`);
    res.json(options);
});

app.post('/login/finish', async (req, res) => {
    const { username, response } = req.body;
    const user = userStore[username];

    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    const authenticator = user.authenticators.find(
        auth => isoBase64URL.fromBuffer(auth.credentialID) === response.id,
    );
    if (!authenticator) {
        return res.status(404).json({ error: 'Authenticator not recognized' });
    }

    try {
        const verification = await verifyAuthenticationResponse({
            response,
            expectedChallenge: user.currentChallenge,
            expectedOrigin: origin,
            expectedRPID: rpID,
            authenticator,
        });

        if (verification.verified) {
            user.currentChallenge = null;
            // The critical step: issue a single-use token for WebSocket upgrade.
            const wsAuthToken = crypto.randomBytes(32).toString('hex');
            // In a real system, this token would be stored in Redis with a short TTL.
            // For simplicity, we'll use an in-memory map.
            wsAuthTokens[wsAuthToken] = { username: user.username, issuedAt: Date.now() };
            logger.info(`User ${username} authenticated successfully. Issued WS token.`);
            res.json({ success: true, wsToken: wsAuthToken });
        } else {
            res.status(401).json({ error: 'Authentication failed' });
        }
    } catch (error) {
        logger.error(`Authentication verification error for ${username}: ${error.message}`);
        res.status(500).json({ error: 'Internal server error' });
    }
});

const server = http.createServer(app);
// The WebSocket server setup and ActiveMQ connection will be attached to this server.
// ... to be continued ...

The wsAuthTokens map is a crucial piece. A common mistake is to reuse the HTTP session cookie for WebSocket authentication. This can be complex and open security holes (e.g., CSRF-like attacks on WebSocket initiation). A dedicated, short-lived, single-use token provides a much cleaner security boundary.

3. The WebSocket and ActiveMQ STOMP Bridge

Now, we attach the WebSocket server and wire it up to ActiveMQ. When a client connects, we validate their single-use token. If valid, we establish the connection and subscribe to the ActiveMQ topic on their behalf.

src/gateway.js:

const WebSocket = require('ws');
const { Stomp } = require('@stomp/stompjs');
const { client: StompClient } = require('stompjs');
const url = require('url');
const config = require('config');
const logger = require('./logger');

// This map holds our single-use tokens.
// In production, use Redis with TTL.
const wsAuthTokens = {}; 

function initializeGateway(server) {
    const wss = new WebSocket.Server({ noServer: true });

    // Connect to ActiveMQ
    const amqConfig = config.get('activemq');
    const stompClient = Stomp.overTCP(amqConfig.host, amqConfig.port);

    stompClient.connect(amqConfig.user, amqConfig.pass,
        () => {
            logger.info('Successfully connected to ActiveMQ via STOMP');
            stompClient.subscribe(amqConfig.topic, (message) => {
                // This handler receives all messages from the topic.
                // We need to broadcast them to all connected clients.
                if (message.body) {
                    logger.debug(`Received message from ActiveMQ: ${message.body}`);
                    wss.clients.forEach(client => {
                        // A more advanced implementation might filter messages
                        // based on client permissions.
                        if (client.readyState === WebSocket.OPEN && client.isAuthenticated) {
                            client.send(message.body);
                        }
                    });
                }
            }, { id: 'audit-log-subscription' });
        },
        (error) => {
            logger.error(`Failed to connect to ActiveMQ: ${error.message}`);
            // Implement retry logic here for production.
            process.exit(1);
        }
    );

    server.on('upgrade', (request, socket, head) => {
        const { query } = url.parse(request.url, true);
        const token = query.token;

        const authToken = wsAuthTokens[token];
        const now = Date.now();

        // Validate the token: it must exist and be recent (e.g., < 10 seconds old).
        if (!authToken || (now - authToken.issuedAt) > 10000) {
            logger.warn(`WebSocket connection rejected: invalid or expired token.`);
            socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
            socket.destroy();
            return;
        }

        // Token is valid, delete it to prevent reuse.
        delete wsAuthTokens[token];

        wss.handleUpgrade(request, socket, head, (ws) => {
            ws.isAuthenticated = true;
            ws.username = authToken.username;
            logger.info(`WebSocket connection established for user: ${ws.username}`);
            wss.emit('connection', ws, request);
        });
    });

    wss.on('connection', ws => {
        ws.on('close', () => {
            logger.info(`WebSocket connection closed for user: ${ws.username}`);
        });
        ws.on('error', (err) => {
            logger.error(`WebSocket error for user ${ws.username}: ${err.message}`);
        });
    });
    
    logger.info('WebSocket Gateway initialized.');
}
// Remember to export this function and call it with the http server instance.
// Also export wsAuthTokens to be used by the server.js file.

This gateway code has a critical performance consideration. The stompClient.subscribe callback iterates through wss.clients on every message. For a very large number of connected clients and a high message rate, this becomes a bottleneck. In such scenarios, a more sophisticated architecture might involve multiple gateway instances and a mechanism (like Redis Pub/Sub) to route ActiveMQ messages to the specific gateway instance holding the target user’s WebSocket connection.

Frontend Implementation: The React Component

The frontend is a React application responsible for the WebAuthn flow and rendering the real-time log viewer. The most important architectural choice here is the use of CSS Modules to guarantee style encapsulation.

1. Styling with CSS Modules

In a large dashboard, multiple components may define a .log-entry or .container class. CSS Modules solves this by localizing class names at build time.

src/components/AuditLogViewer.module.css:

/*
  By convention, CSS Module files end with .module.css
  All class names defined here will be locally scoped.
*/
.logContainer {
  font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
  background-color: #1e1e1e;
  color: #d4d4d4;
  border: 1px solid #333;
  border-radius: 4px;
  height: 500px;
  overflow-y: auto;
  padding: 10px;
  display: flex;
  flex-direction: column-reverse; /* Newest logs at the bottom */
}

.logEntry {
  padding: 4px 8px;
  margin-bottom: 2px;
  white-space: pre-wrap;
  word-break: break-all;
  border-radius: 2px;
  transition: background-color 0.3s ease;
}

.logEntry:hover {
  background-color: #2a2d2e;
}

/* Example of status-based styling */
.info {
  border-left: 3px solid #007acc;
}

.warn {
  border-left: 3px solid #f1c40f;
  color: #f1c40f;
}

.error {
  border-left: 3px solid #e74c3c;
  color: #e74c3c;
  background-color: rgba(231, 76, 60, 0.1);
}

.statusIndicator {
    position: absolute;
    top: 10px;
    right: 10px;
    padding: 5px 10px;
    border-radius: 12px;
    font-size: 0.8em;
    font-weight: bold;
}

.connected {
    background-color: #2ecc71;
    color: white;
}

.disconnected {
    background-color: #e74c3c;
    color: white;
}

2. The React Component and WebSocket Logic

The AuditLogViewer component manages its own state, including the WebSocket connection, connection status, and the list of log messages. The pitfall here is managing the WebSocket lifecycle correctly within React’s component lifecycle hooks. Using useEffect is key to establishing and cleaning up the connection.

src/components/AuditLogViewer.js:

import React, { useState, useEffect, useRef } from 'react';
import styles from './AuditLogViewer.module.css'; // Import CSS Module

const MAX_LOGS = 200; // Cap the number of logs to prevent memory issues

const AuditLogViewer = ({ wsToken }) => {
  const [logs, setLogs] = useState([]);
  const [status, setStatus] = useState('Disconnected');
  const ws = useRef(null);

  useEffect(() => {
    if (!wsToken) {
      return; // Do nothing if we don't have a token yet
    }

    // A common mistake is not handling reconnects gracefully.
    // This implementation uses a simple approach, but a production
    // system should use exponential backoff.
    const connect = () => {
      // The WebSocket URL includes the single-use token.
      const wsUrl = `ws://localhost:8080?token=${wsToken}`;
      ws.current = new WebSocket(wsUrl);
      
      ws.current.onopen = () => {
        console.log('WebSocket connection established.');
        setStatus('Connected');
      };

      ws.current.onmessage = (event) => {
        try {
          const newLog = JSON.parse(event.data);
          
          // The key to performance is to avoid re-rendering the entire list.
          // By providing a unique key to each log entry, React can
          // efficiently update the DOM.
          newLog.id = new Date().getTime() + Math.random(); 

          setLogs(prevLogs => {
            const updatedLogs = [newLog, ...prevLogs];
            // Prevent the logs array from growing indefinitely.
            if (updatedLogs.length > MAX_LOGS) {
              return updatedLogs.slice(0, MAX_LOGS);
            }
            return updatedLogs;
          });
        } catch (error) {
          console.error('Failed to parse incoming log message:', event.data);
        }
      };

      ws.current.onclose = () => {
        console.log('WebSocket connection closed.');
        setStatus('Disconnected');
        // Simple retry mechanism. In production, use exponential backoff.
        // Note: Retrying with the same single-use token will fail.
        // A robust implementation needs a mechanism to get a new wsToken.
      };

      ws.current.onerror = (error) => {
        console.error('WebSocket error:', error);
        setStatus('Error');
        ws.current.close();
      };
    };

    connect();

    // The cleanup function is critical to prevent memory leaks.
    return () => {
      if (ws.current) {
        ws.current.close();
      }
    };
  }, [wsToken]); // The effect re-runs only if wsToken changes.

  const getLogLevelClass = (level) => {
    switch (level?.toLowerCase()) {
      case 'warn': return styles.warn;
      case 'error': return styles.error;
      case 'info':
      default:
        return styles.info;
    }
  };

  return (
    <div className={styles.logContainerWrapper}>
      <div className={`${styles.statusIndicator} ${status === 'Connected' ? styles.connected : styles.disconnected}`}>
        {status}
      </div>
      <div className={styles.logContainer}>
        {logs.map((log) => (
          <div key={log.id} className={`${styles.logEntry} ${getLogLevelClass(log.level)}`}>
            <strong>{new Date(log.timestamp).toISOString()}</strong> [{log.level?.toUpperCase()}] - {log.message}
          </div>
        ))}
      </div>
    </div>
  );
};

The use of styles.logContainer instead of a string "logContainer" is the core mechanism of CSS Modules. The build tool (like Webpack or Vite) transforms styles.logContainer into a unique class name like AuditLogViewer_logContainer__2a1b3, ensuring it never conflicts with other CSS on the page.

Limitations and Future Iterations

This implementation successfully creates a secure, real-time data streaming pipeline, but it is not without its limitations in a large-scale production environment. The WebSocket gateway remains a single point of failure. To achieve high availability, a cluster of gateway instances would be necessary, running behind a load balancer configured for sticky sessions. This, however, complicates the message broadcasting from ActiveMQ, as a message arriving at one gateway instance must be routed to a user connected to a different instance. This is typically solved using a shared backplane like Redis Pub/Sub.

Furthermore, the client-side reconnection logic is naive. A production-grade component should implement an exponential backoff strategy and, more importantly, a mechanism to request a new wsToken via an authenticated HTTP request if the WebSocket connection fails, since the original token is single-use. Finally, for extremely high-volume streams, rendering performance could be degraded. Virtualized lists (e.g., using react-window) would be required to ensure the DOM is not overwhelmed, rendering only the log entries currently visible in the viewport.


  TOC