// Credential minter for Cloudflare Realtime TURN. // // The browser cannot call Cloudflare's TURN API directly: doing so would mean // shipping the long-lived TURN API token in a static bundle, and anyone could // then mint unlimited credentials against the account. This Worker holds that // token and hands out short-lived ICE configurations to callers that present // one of the room tokens configured in ROOM_TOKENS. // // POST / Authorization: Bearer // {"room": ""} // -> 200 {"iceServers": [...], "expiresAt": } // // The `room` tag is passed to Cloudflare as the credential's customIdentifier // so usage can be attributed per room in the Realtime analytics. The client // sends a prefix of the hashed room topic, never the room name itself. export interface Env { /** TURN key ID from the Cloudflare dashboard (Realtime -> TURN). */ TURN_KEY_ID: string /** API token paired with that TURN key. Never leaves this Worker. */ TURN_KEY_API_TOKEN: string /** Accepted room tokens, separated by commas or whitespace. */ ROOM_TOKENS: string /** Comma-separated origins allowed to call this Worker, or "*". */ ALLOWED_ORIGINS?: string /** Lifetime of an issued credential, in seconds (Cloudflare's max is 48 h). */ CREDENTIAL_TTL?: string } const DEFAULT_TTL_SECONDS = 6 * 60 * 60 const MAX_TTL_SECONDS = 48 * 60 * 60 export default { async fetch(request: Request, env: Env): Promise { const origin = request.headers.get('Origin') const allowedOrigin = resolveOrigin(origin, env.ALLOWED_ORIGINS) const cors: Record = { vary: 'Origin', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'Authorization, Content-Type', 'access-control-max-age': '86400' } if (allowedOrigin) cors['access-control-allow-origin'] = allowedOrigin if (request.method === 'OPTIONS') { return new Response(null, {status: 204, headers: cors}) } if (request.method !== 'POST') { return json({error: 'Use POST.'}, 405, cors) } // A browser would block the response anyway; answering plainly makes a // misconfigured ALLOWED_ORIGINS obvious in the network tab. if (origin && !allowedOrigin) { return json({error: 'Origin not allowed.'}, 403, cors) } if (!env.TURN_KEY_ID || !env.TURN_KEY_API_TOKEN || !env.ROOM_TOKENS) { return json({error: 'Relay is not configured.'}, 500, cors) } const header = request.headers.get('Authorization') ?? '' const token = header.startsWith('Bearer ') ? header.slice(7).trim() : '' if (!token || !(await tokenAccepted(token, env.ROOM_TOKENS))) { return json({error: 'Invalid relay token.'}, 401, cors) } let customIdentifier: string | undefined try { const body = (await request.json()) as {room?: unknown} if (typeof body?.room === 'string' && body.room) { // Keep it short and inert: this string ends up in Cloudflare's // analytics, and only ever needs to distinguish one room from another. customIdentifier = body.room.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) } } catch { /* no body, or not JSON — the tag is optional */ } const ttl = clampTtl(env.CREDENTIAL_TTL) let upstream: Response try { upstream = await fetch( `https://rtc.live.cloudflare.com/v1/turn/keys/${env.TURN_KEY_ID}/credentials/generate-ice-servers`, { method: 'POST', headers: { Authorization: `Bearer ${env.TURN_KEY_API_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify( customIdentifier ? {ttl, customIdentifier} : {ttl} ) } ) } catch { return json({error: 'Could not reach the relay service.'}, 502, cors) } if (!upstream.ok) { // Deliberately vague: the upstream body can echo account details. console.error('generate-ice-servers failed', upstream.status) return json( {error: `Relay service returned ${upstream.status}.`}, 502, cors ) } const payload = (await upstream.json()) as {iceServers?: unknown} if (!payload || typeof payload !== 'object' || !payload.iceServers) { return json({error: 'Malformed response from the relay service.'}, 502, cors) } return json( { iceServers: payload.iceServers, // The client shares this with the rest of the room and re-mints before // it lapses. A small safety margin absorbs clock skew between peers. expiresAt: Date.now() + (ttl - 60) * 1000 }, 200, {...cors, 'cache-control': 'no-store'} ) } } satisfies ExportedHandler const json = ( body: unknown, status: number, headers: Record ): Response => new Response(JSON.stringify(body), { status, headers: {...headers, 'content-type': 'application/json; charset=utf-8'} }) const resolveOrigin = ( origin: string | null, allowed: string | undefined ): string | null => { const list = (allowed ?? '*') .split(',') .map(s => s.trim()) .filter(Boolean) if (list.includes('*')) return origin ?? '*' if (origin && list.includes(origin)) return origin return null } const clampTtl = (raw: string | undefined): number => { const n = Number(raw) if (!Number.isFinite(n) || n <= 0) return DEFAULT_TTL_SECONDS return Math.min(Math.floor(n), MAX_TTL_SECONDS) } /** Compare against each configured token in constant time. Hashing first makes * the comparison independent of token length as well as content. */ const tokenAccepted = async ( token: string, configured: string ): Promise => { const candidates = configured.split(/[\s,]+/).filter(Boolean) if (candidates.length === 0) return false const offered = await sha256(token) let ok = false for (const c of candidates) { if (equalBytes(offered, await sha256(c))) ok = true } return ok } const sha256 = async (s: string): Promise => new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))) const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => { if (a.length !== b.length) return false let diff = 0 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i] return diff === 0 }