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:
52
decnet_web/src/utils/sseTicket.test.ts
Normal file
52
decnet_web/src/utils/sseTicket.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the api module BEFORE importing the unit under test so the module
|
||||
// factory runs first and replaces the real axios instance.
|
||||
vi.mock('./api', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import api from './api';
|
||||
import { mintSseTicket } from './sseTicket';
|
||||
|
||||
const mockPost = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('mintSseTicket', () => {
|
||||
it('POSTs to /auth/sse-ticket and returns the ticket string', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { ticket: 'opaque-abc-123', expires_in: 60 },
|
||||
});
|
||||
|
||||
const ticket = await mintSseTicket();
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/sse-ticket');
|
||||
expect(ticket).toBe('opaque-abc-123');
|
||||
});
|
||||
|
||||
it('propagates API errors to the caller', async () => {
|
||||
const err = Object.assign(new Error('Unauthorized'), {
|
||||
response: { status: 401, data: { detail: 'Could not validate credentials' } },
|
||||
});
|
||||
mockPost.mockRejectedValueOnce(err);
|
||||
|
||||
await expect(mintSseTicket()).rejects.toThrow('Unauthorized');
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/sse-ticket');
|
||||
});
|
||||
|
||||
it('propagates network errors (no response object) to the caller', async () => {
|
||||
mockPost.mockRejectedValueOnce(new Error('Network Error'));
|
||||
|
||||
await expect(mintSseTicket()).rejects.toThrow('Network Error');
|
||||
});
|
||||
});
|
||||
26
decnet_web/src/utils/sseTicket.ts
Normal file
26
decnet_web/src/utils/sseTicket.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/**
|
||||
* SSE ticket helper — mints a single-use opaque ticket for authenticating
|
||||
* an EventSource connection. Native EventSource cannot set an Authorization
|
||||
* header, so the backend issues a short-lived (?60 s) ticket via a normal
|
||||
* Bearer-authenticated REST call and the ticket is passed as ?ticket= on
|
||||
* the stream URL.
|
||||
*
|
||||
* IMPORTANT: the ticket is SINGLE-USE. Mint a fresh ticket for every
|
||||
* connection attempt — initial connect AND every reconnect.
|
||||
*/
|
||||
import api from './api';
|
||||
|
||||
/**
|
||||
* POST /auth/sse-ticket with the normal Bearer JWT (attached automatically
|
||||
* by the axios `api` instance) and return the opaque ticket string.
|
||||
*
|
||||
* Throws if the API call fails (e.g. 401 when the JWT has expired).
|
||||
* Callers are responsible for handling the error — typically by invoking
|
||||
* their existing onError handler and scheduling a reconnect, which will
|
||||
* cause the axios 401 interceptor to fire `auth:logout`.
|
||||
*/
|
||||
export async function mintSseTicket(): Promise<string> {
|
||||
const res = await api.post<{ ticket: string; expires_in: number }>('/auth/sse-ticket');
|
||||
return res.data.ticket;
|
||||
}
|
||||
Reference in New Issue
Block a user