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:
2026-05-02 17:36:28 +02:00
parent 8a78e53e58
commit 20ebd9b473
4 changed files with 513 additions and 16 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* Cookie-based authentication for WebSocket connections.
*
* Validates the Directus-issued cookie attached to the upgrade request by
* making a single GET /users/me round-trip to Directus. On success, returns
* the user identity that is bound to the connection for its lifetime.
*
* Design notes:
* - No JWT validation locally — the round-trip is simpler, correct, and fast
* enough at pilot scale (≤500 viewers).
* - No retries — a failed validation immediately closes the upgrade. The SPA
* reconnects, giving a natural retry. Server-side retry masks credential bugs.
* - The entire cookie header is forwarded verbatim to Directus — Directus owns
* cookie parsing and session lookup.
*
* Spec: docs/wiki/synthesis/processor-ws-contract.md §Auth handshake
*/
import { z } from 'zod';
import type { Config } from '../config/load.js';
import type { Metrics } from '../shared/types.js';
import type { Logger } from 'pino';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* The minimum user fields needed for per-subscription authorization (1.5.3)
* and Phase 4 permission enforcement.
*/
const AuthenticatedUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email().nullable().optional(),
role: z.string().uuid().nullable().optional(),
first_name: z.string().nullable().optional(),
last_name: z.string().nullable().optional(),
});
export type AuthenticatedUser = z.infer<typeof AuthenticatedUserSchema>;
/**
* Public interface returned by `createAuthClient`.
*/
export type AuthClient = {
/**
* Validates a raw `Cookie:` header value against Directus's `/users/me`.
*
* Returns the user identity on success, or `null` on any failure
* (network error, 401, malformed response, timeout). Never throws.
*/
readonly validate: (cookieHeader: string) => Promise<AuthenticatedUser | null>;
};
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createAuthClient(
config: Config,
logger: Logger,
metrics: Metrics,
): AuthClient {
async function validate(cookieHeader: string): Promise<AuthenticatedUser | null> {
if (!cookieHeader) return null;
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
config.DIRECTUS_AUTH_TIMEOUT_MS,
);
const start = performance.now();
try {
const res = await fetch(
`${config.DIRECTUS_BASE_URL}/users/me?fields=id,email,role,first_name,last_name`,
{
method: 'GET',
headers: { cookie: cookieHeader },
signal: controller.signal,
},
);
if (res.status === 401 || res.status === 403) {
metrics.inc('processor_live_auth_attempts_total', { result: 'unauthorized' });
return null;
}
if (!res.ok) {
logger.warn({ status: res.status }, 'directus /users/me returned non-2xx');
metrics.inc('processor_live_auth_attempts_total', { result: 'error' });
return null;
}
// Directus returns { data: {...} } for /users/me.
const body = await res.json() as Record<string, unknown>;
// Check whether the `data` key is present at all. If it is missing
// entirely, that is an unexpected Directus response shape.
if (!('data' in body)) {
logger.warn('directus /users/me response missing data field');
metrics.inc('processor_live_auth_attempts_total', { result: 'error' });
return null;
}
const data = body['data'];
if (data === null || data === undefined) {
// Directus returns data: null when the session is expired but the
// cookie is structurally valid. Treat as unauthorized.
logger.warn('directus /users/me returned null data (expired session)');
metrics.inc('processor_live_auth_attempts_total', { result: 'unauthorized' });
return null;
}
if (typeof data !== 'object') {
logger.warn({ data }, 'directus /users/me data field is not an object');
metrics.inc('processor_live_auth_attempts_total', { result: 'error' });
return null;
}
const parsed = AuthenticatedUserSchema.safeParse(data);
if (!parsed.success) {
logger.warn(
{ issues: parsed.error.issues },
'directus /users/me returned unexpected shape',
);
metrics.inc('processor_live_auth_attempts_total', { result: 'error' });
return null;
}
metrics.inc('processor_live_auth_attempts_total', { result: 'success' });
return parsed.data;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
logger.warn(
{ timeoutMs: config.DIRECTUS_AUTH_TIMEOUT_MS },
'directus auth call timed out',
);
} else {
logger.warn({ err }, 'directus auth call failed');
}
metrics.inc('processor_live_auth_attempts_total', { result: 'error' });
return null;
} finally {
clearTimeout(timer);
metrics.observe('processor_live_auth_latency_ms', performance.now() - start);
}
}
return { validate };
}