feat(live): task 1.5.2 — cookie auth handshake
Authenticate WebSocket upgrade requests via Directus's /users/me: - src/live/auth.ts: createAuthClient factory; validate() forwards the raw Cookie: header to Directus, parses the user with zod, and returns AuthenticatedUser or null. Handles 401/403 (unauthorized), non-2xx (error), network failures, AbortError (timeout), null data (expired session), and missing data key (malformed Directus response). - src/live/server.ts: upgrade handler now calls authClient.validate() before completing the WS handshake; on null user, writes HTTP 401 and destroys the socket. LiveConnection gains user: AuthenticatedUser and cookieHeader: string (needed for per-subscription authz in task 1.5.3). authClient is an optional parameter so tests without auth still work. - src/main.ts: wires createAuthClient and passes it to createLiveServer. - test/live-auth.test.ts: 11 unit tests covering all validate() code paths including the empty-cookie fast-path, latency histogram observation, and distinction between unauthorized (401/expired) and error (malformed) results.
This commit is contained in:
+81
-14
@@ -8,14 +8,16 @@
|
||||
* - Runs on its own http.Server (separate from the Phase 1 metrics/health server
|
||||
* on :9090) so a proxy can route to different paths and failure modes don't
|
||||
* entangle.
|
||||
* - Auth happens in the `'upgrade'` handler (task 1.5.2). This scaffold accepts
|
||||
* all upgrades and logs the connection.
|
||||
* - Message dispatch is pluggable via the `onMessage` callback so tasks 1.5.2
|
||||
* and 1.5.3 can attach the real auth/registry handler without touching this
|
||||
* file's lifecycle logic.
|
||||
* - Auth runs in the `'upgrade'` handler: validate the cookie via Directus before
|
||||
* completing the WS upgrade. Rejected upgrades get an HTTP 401 response.
|
||||
* - Message dispatch is pluggable via the `onMessage` callback so task 1.5.3
|
||||
* can attach the real subscription-registry handler.
|
||||
* - Heartbeat: WS frame-level ping every LIVE_WS_PING_INTERVAL_MS; pong updates
|
||||
* lastSeenAt. Do NOT use application-level ping messages — browser WS
|
||||
* implementations handle frame-level pings natively.
|
||||
* - cookieHeader is stored on the connection so the authz client (task 1.5.3)
|
||||
* can forward it to Directus for per-event authorization. It is sensitive
|
||||
* material; never log it.
|
||||
*/
|
||||
|
||||
import * as http from 'node:http';
|
||||
@@ -26,15 +28,17 @@ import type { Config } from '../config/load.js';
|
||||
import type { Metrics } from '../shared/types.js';
|
||||
import { InboundMessage, WsCloseCodes } from './protocol.js';
|
||||
import type { OutboundMessage } from './protocol.js';
|
||||
import type { AuthClient, AuthenticatedUser } from './auth.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-connection identity object. Augmented in later tasks (auth adds `user`;
|
||||
* task 1.5.3 adds `cookieHeader`). Exported so the registry, auth, and
|
||||
* broadcast modules can reference the same type.
|
||||
* Per-connection identity object. Holds the validated user identity and the
|
||||
* original cookie header (needed for per-subscription authorization in 1.5.3).
|
||||
*
|
||||
* `cookieHeader` is sensitive — never log it.
|
||||
*/
|
||||
export type LiveConnection = {
|
||||
readonly id: string;
|
||||
@@ -42,14 +46,17 @@ export type LiveConnection = {
|
||||
readonly remoteAddr: string;
|
||||
readonly openedAt: Date;
|
||||
lastSeenAt: Date;
|
||||
readonly user: AuthenticatedUser;
|
||||
/** The raw Cookie: header from the upgrade request. Used by the authz client
|
||||
* to forward the user's session when checking event access. */
|
||||
readonly cookieHeader: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message handler callback. The server calls this once per successfully parsed
|
||||
* inbound message. The handler is responsible for sending replies.
|
||||
*
|
||||
* In task 1.5.1 this is a no-op stub that returns `error/not-implemented`.
|
||||
* Tasks 1.5.2 and 1.5.3 replace it with the real auth+registry handler.
|
||||
* Task 1.5.3 replaces the stub with the real subscription-registry handler.
|
||||
*/
|
||||
export type MessageHandler = (
|
||||
conn: LiveConnection,
|
||||
@@ -120,6 +127,7 @@ export function createLiveServer(
|
||||
metrics: Metrics,
|
||||
onMessage: MessageHandler,
|
||||
onClose?: (conn: LiveConnection) => void,
|
||||
authClient?: AuthClient,
|
||||
): LiveServer {
|
||||
const connections = new Map<string, LiveConnection>();
|
||||
|
||||
@@ -131,12 +139,53 @@ export function createLiveServer(
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Upgrade handler (auth injected in task 1.5.2; accepted immediately here)
|
||||
// Upgrade handler — validates auth before completing the WS handshake
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
const cookieHeader = req.headers['cookie'] ?? '';
|
||||
|
||||
if (!authClient) {
|
||||
// No auth client provided — accept the upgrade without validation.
|
||||
// Used in tests that don't need auth.
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req, '', { id: 'anonymous', email: null, role: null, first_name: null, last_name: null } satisfies AuthenticatedUser);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the cookie asynchronously. The upgrade handler must not hold
|
||||
// the socket open for too long — the auth timeout (5s default) is the
|
||||
// upper bound.
|
||||
authClient.validate(cookieHeader).then((user) => {
|
||||
if (!user) {
|
||||
socket.write(
|
||||
'HTTP/1.1 401 Unauthorized\r\n' +
|
||||
'Content-Length: 0\r\n' +
|
||||
'Connection: close\r\n' +
|
||||
'\r\n',
|
||||
);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stash user + cookieHeader on the request so the connection handler
|
||||
// can pick them up without a second async call.
|
||||
(req as http.IncomingMessage & { _liveUser: AuthenticatedUser; _liveCookie: string })._liveUser = user;
|
||||
(req as http.IncomingMessage & { _liveUser: AuthenticatedUser; _liveCookie: string })._liveCookie = cookieHeader;
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
}).catch((err: unknown) => {
|
||||
logger.error({ err }, 'auth validation threw unexpectedly during upgrade');
|
||||
socket.write(
|
||||
'HTTP/1.1 500 Internal Server Error\r\n' +
|
||||
'Content-Length: 0\r\n' +
|
||||
'Connection: close\r\n' +
|
||||
'\r\n',
|
||||
);
|
||||
socket.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,19 +194,37 @@ export function createLiveServer(
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
wss.on('connection', (ws, req: http.IncomingMessage) => {
|
||||
// Retrieve the user stashed by the upgrade handler. When auth is disabled
|
||||
// (no authClient), fall back to a placeholder anonymous user.
|
||||
type AugmentedRequest = http.IncomingMessage & {
|
||||
_liveUser?: AuthenticatedUser;
|
||||
_liveCookie?: string;
|
||||
};
|
||||
const augmented = req as AugmentedRequest;
|
||||
const user: AuthenticatedUser = augmented._liveUser ?? {
|
||||
id: crypto.randomUUID(),
|
||||
email: null,
|
||||
role: null,
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
};
|
||||
const cookieHeader = augmented._liveCookie ?? '';
|
||||
|
||||
const conn: LiveConnection = {
|
||||
id: crypto.randomUUID(),
|
||||
ws,
|
||||
remoteAddr: req.socket.remoteAddress ?? 'unknown',
|
||||
openedAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
user,
|
||||
cookieHeader,
|
||||
};
|
||||
|
||||
connections.set(conn.id, conn);
|
||||
metrics.observe('processor_live_connections', connections.size);
|
||||
|
||||
logger.debug(
|
||||
{ connId: conn.id, remote: conn.remoteAddr },
|
||||
{ connId: conn.id, remote: conn.remoteAddr, userId: user.id },
|
||||
'connection opened',
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user