import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; export type MeetingRole = "host" | "participant"; export type MeetingTokenPayload = { eventSlug: string; participantId: string; // Self-declared, persisted client-side (localStorage, not sessionStorage) // identity for participants — participantId is fresh per /join call, so // without this a kick could always be undone by simply rejoining. Only // used to make a kick stick across a rejoin (see rooms.ts kickedClientIds); // never used to key live room membership, so a client choosing an // arbitrary/colliding value can only affect their own future kick status, // never another participant's connection. Still bypassable by clearing // localStorage or joining from a different browser — there's no real // identity to anchor to for an unauthenticated participant. clientId?: string; name: string; role: MeetingRole; accessVersion?: string; testRoom?: boolean; exp: number; // unix ms }; function secret(): string { const value = process.env.MEETING_SESSION_SECRET; if (!value) throw new Error("MEETING_SESSION_SECRET is not set"); return value; } function base64url(input: Buffer | string): string { return Buffer.from(input).toString("base64url"); } function sign(data: string): string { return createHmac("sha256", secret()).update(data).digest("base64url"); } /** Issues a short-lived, self-contained, signed token for one meeting session. */ export function createMeetingToken(args: { eventSlug: string; name: string; role: MeetingRole; clientId?: string; ttlMs?: number; accessVersion?: string; testRoom?: boolean; }): { token: string; participantId: string } { const participantId = randomUUID(); const clientId = typeof args.clientId === "string" && args.clientId.length > 0 && args.clientId.length <= 100 ? args.clientId : undefined; const payload: MeetingTokenPayload = { eventSlug: args.eventSlug, participantId, clientId, name: args.name, role: args.role, accessVersion: args.accessVersion, testRoom: args.testRoom, exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default }; const body = base64url(JSON.stringify(payload)); const signature = sign(body); return { token: `${body}.${signature}`, participantId }; } /** Verifies signature + expiry. Returns null if invalid/expired/tampered. */ export function verifyMeetingToken(token: unknown): MeetingTokenPayload | null { if (typeof token !== "string" || token.length > 4096) return null; const parts = token.split("."); if (parts.length !== 2) return null; const [body, signature] = parts; const expected = sign(body); const a = Buffer.from(signature); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; try { const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as MeetingTokenPayload; if ( !Number.isFinite(payload.exp) || payload.exp <= Date.now() || (payload.accessVersion !== undefined && !/^[0-9a-f]{64}$/.test(payload.accessVersion)) || (payload.testRoom !== undefined && typeof payload.testRoom !== "boolean") || typeof payload.eventSlug !== "string" || payload.eventSlug.length > 200 || typeof payload.participantId !== "string" || payload.participantId.length > 100 || typeof payload.name !== "string" || payload.name.length > 120 || (payload.role !== "host" && payload.role !== "participant") ) return null; return payload; } catch { return null; } }