/** * 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; /** * 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; }; // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- export function createAuthClient( config: Config, logger: Logger, metrics: Metrics, ): AuthClient { async function validate(cookieHeader: string): Promise { 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; // 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 }; }