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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user