fix(web): restore SSE streams via single-use ticket flow

The V3.1.1 backend change moved SSE auth off ?token=<JWT> onto a single-use
?ticket=, but the dashboard was never updated, so every live stream 401'd
('Could not validate credentials'). Add mintSseTicket() (POST /auth/sse-ticket
with the Bearer JWT, returns an opaque 60s single-use ticket) and refactor all
stream consumers to mint a fresh ticket at the top of each connect() — initial
and every reconnect — then open EventSource with ?ticket=. A reused single-use
ticket would 401-loop, so re-mint-per-connect is required.

Covers Dashboard /stream, LiveLogs, and the attacker/identity/campaign/
orchestrator/topology hooks. connect() is now async with an unmount guard
(cancelled flag checked after the await, before opening the stream); on a mint
401 the connect is skipped and the axios logout interceptor takes over.
This commit is contained in:
2026-06-12 19:00:15 -04:00
parent 593492411c
commit efe4e49de6
9 changed files with 257 additions and 57 deletions

View File

@@ -10,6 +10,7 @@
* pending topologies don't open a useless channel.
*/
import { useEffect, useRef } from 'react';
import { mintSseTicket } from '../../utils/sseTicket';
export type TopologyStreamEventName =
| 'snapshot'
@@ -69,11 +70,25 @@ export function useTopologyStream({
useEffect(() => {
if (!enabled || !topologyId) return;
const connect = () => {
let cancelled = false;
const connect = async () => {
if (esRef.current) esRef.current.close();
const token = localStorage.getItem('token') ?? '';
let ticket: string;
try {
ticket = await mintSseTicket();
} catch {
onErrorRef.current?.();
if (!cancelled) {
reconnectRef.current = setTimeout(connect, 3000);
}
return;
}
if (cancelled) return;
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const url = `${baseUrl}/topologies/${topologyId}/events?token=${encodeURIComponent(token)}`;
const url = `${baseUrl}/topologies/${topologyId}/events?ticket=${encodeURIComponent(ticket)}`;
const es = new EventSource(url);
esRef.current = es;
@@ -101,13 +116,16 @@ export function useTopologyStream({
es.close();
esRef.current = null;
onErrorRef.current?.();
reconnectRef.current = setTimeout(connect, 3000);
if (!cancelled) {
reconnectRef.current = setTimeout(connect, 3000);
}
};
};
connect();
return () => {
cancelled = true;
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (esRef.current) esRef.current.close();
esRef.current = null;