/ concept-collection / commonroom
concept-collection / commonroom
commonroom / worker / src / index.ts
179 lines · 6.3 KBBlameHistoryRaw
1// Credential minter for Cloudflare Realtime TURN.
2//
3// The browser cannot call Cloudflare's TURN API directly: doing so would mean
4// shipping the long-lived TURN API token in a static bundle, and anyone could
5// then mint unlimited credentials against the account. This Worker holds that
6// token and hands out short-lived ICE configurations to callers that present
7// one of the room tokens configured in ROOM_TOKENS.
8//
9// POST / Authorization: Bearer <room token>
10// {"room": "<opaque tag, optional>"}
11// -> 200 {"iceServers": [...], "expiresAt": <epoch ms>}
12//
13// The `room` tag is passed to Cloudflare as the credential's customIdentifier
14// so usage can be attributed per room in the Realtime analytics. The client
15// sends a prefix of the hashed room topic, never the room name itself.
17export interface Env {
18 /** TURN key ID from the Cloudflare dashboard (Realtime -> TURN). */
19 TURN_KEY_ID: string
20 /** API token paired with that TURN key. Never leaves this Worker. */
21 TURN_KEY_API_TOKEN: string
22 /** Accepted room tokens, separated by commas or whitespace. */
23 ROOM_TOKENS: string
24 /** Comma-separated origins allowed to call this Worker, or "*". */
25 ALLOWED_ORIGINS?: string
26 /** Lifetime of an issued credential, in seconds (Cloudflare's max is 48 h). */
27 CREDENTIAL_TTL?: string
30const DEFAULT_TTL_SECONDS = 6 * 60 * 60
31const MAX_TTL_SECONDS = 48 * 60 * 60
33export default {
34 async fetch(request: Request, env: Env): Promise<Response> {
35 const origin = request.headers.get('Origin')
36 const allowedOrigin = resolveOrigin(origin, env.ALLOWED_ORIGINS)
37 const cors: Record<string, string> = {
38 vary: 'Origin',
39 'access-control-allow-methods': 'POST, OPTIONS',
40 'access-control-allow-headers': 'Authorization, Content-Type',
41 'access-control-max-age': '86400'
42 }
43 if (allowedOrigin) cors['access-control-allow-origin'] = allowedOrigin
45 if (request.method === 'OPTIONS') {
46 return new Response(null, {status: 204, headers: cors})
47 }
48 if (request.method !== 'POST') {
49 return json({error: 'Use POST.'}, 405, cors)
50 }
51 // A browser would block the response anyway; answering plainly makes a
52 // misconfigured ALLOWED_ORIGINS obvious in the network tab.
53 if (origin && !allowedOrigin) {
54 return json({error: 'Origin not allowed.'}, 403, cors)
55 }
56 if (!env.TURN_KEY_ID || !env.TURN_KEY_API_TOKEN || !env.ROOM_TOKENS) {
57 return json({error: 'Relay is not configured.'}, 500, cors)
58 }
60 const header = request.headers.get('Authorization') ?? ''
61 const token = header.startsWith('Bearer ') ? header.slice(7).trim() : ''
62 if (!token || !(await tokenAccepted(token, env.ROOM_TOKENS))) {
63 return json({error: 'Invalid relay token.'}, 401, cors)
64 }
66 let customIdentifier: string | undefined
67 try {
68 const body = (await request.json()) as {room?: unknown}
69 if (typeof body?.room === 'string' && body.room) {
70 // Keep it short and inert: this string ends up in Cloudflare's
71 // analytics, and only ever needs to distinguish one room from another.
72 customIdentifier = body.room.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64)
73 }
74 } catch {
75 /* no body, or not JSON — the tag is optional */
76 }
78 const ttl = clampTtl(env.CREDENTIAL_TTL)
79 let upstream: Response
80 try {
81 upstream = await fetch(
82 `https://rtc.live.cloudflare.com/v1/turn/keys/${env.TURN_KEY_ID}/credentials/generate-ice-servers`,
83 {
84 method: 'POST',
85 headers: {
86 Authorization: `Bearer ${env.TURN_KEY_API_TOKEN}`,
87 'Content-Type': 'application/json'
88 },
89 body: JSON.stringify(
90 customIdentifier ? {ttl, customIdentifier} : {ttl}
91 )
92 }
93 )
94 } catch {
95 return json({error: 'Could not reach the relay service.'}, 502, cors)
96 }
98 if (!upstream.ok) {
99 // Deliberately vague: the upstream body can echo account details.
100 console.error('generate-ice-servers failed', upstream.status)
101 return json(
102 {error: `Relay service returned ${upstream.status}.`},
103 502,
104 cors
105 )
106 }
108 const payload = (await upstream.json()) as {iceServers?: unknown}
109 if (!payload || typeof payload !== 'object' || !payload.iceServers) {
110 return json({error: 'Malformed response from the relay service.'}, 502, cors)
111 }
113 return json(
114 {
115 iceServers: payload.iceServers,
116 // The client shares this with the rest of the room and re-mints before
117 // it lapses. A small safety margin absorbs clock skew between peers.
118 expiresAt: Date.now() + (ttl - 60) * 1000
119 },
120 200,
121 {...cors, 'cache-control': 'no-store'}
122 )
123 }
124} satisfies ExportedHandler<Env>
126const json = (
127 body: unknown,
128 status: number,
129 headers: Record<string, string>
130): Response =>
131 new Response(JSON.stringify(body), {
132 status,
133 headers: {...headers, 'content-type': 'application/json; charset=utf-8'}
134 })
136const resolveOrigin = (
137 origin: string | null,
138 allowed: string | undefined
139): string | null => {
140 const list = (allowed ?? '*')
141 .split(',')
142 .map(s => s.trim())
143 .filter(Boolean)
144 if (list.includes('*')) return origin ?? '*'
145 if (origin && list.includes(origin)) return origin
146 return null
149const clampTtl = (raw: string | undefined): number => {
150 const n = Number(raw)
151 if (!Number.isFinite(n) || n <= 0) return DEFAULT_TTL_SECONDS
152 return Math.min(Math.floor(n), MAX_TTL_SECONDS)
155/** Compare against each configured token in constant time. Hashing first makes
156 * the comparison independent of token length as well as content. */
157const tokenAccepted = async (
158 token: string,
159 configured: string
160): Promise<boolean> => {
161 const candidates = configured.split(/[\s,]+/).filter(Boolean)
162 if (candidates.length === 0) return false
163 const offered = await sha256(token)
164 let ok = false
165 for (const c of candidates) {
166 if (equalBytes(offered, await sha256(c))) ok = true
167 }
168 return ok
171const sha256 = async (s: string): Promise<Uint8Array> =>
172 new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))
174const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => {
175 if (a.length !== b.length) return false
176 let diff = 0
177 for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
178 return diff === 0