fix(voice-call): pass Twilio stream auth token via <Parameter> instead of query string (#14029)

Twilio strips query parameters from WebSocket URLs in <Stream> TwiML,
so the auth token set via ?token=xxx never arrives on the WebSocket
connection. This causes stream rejection when token validation is enabled.

Fix: pass the token as a <Parameter> element inside <Stream>, which
Twilio delivers in the start message's customParameters field. The
media stream handler now extracts the token from customParameters,
falling back to query string for backwards compatibility.

Co-authored-by: McWiggles <mcwigglesmcgee@users.noreply.github.com>
This commit is contained in:
mcwigglesmcgee
2026-02-12 05:55:00 -08:00
committed by GitHub
parent f8c91b3c5f
commit f8cad44cd6
2 changed files with 19 additions and 2 deletions

View File

@@ -146,6 +146,11 @@ export class MediaStreamHandler {
const streamSid = message.streamSid || ""; const streamSid = message.streamSid || "";
const callSid = message.start?.callSid || ""; const callSid = message.start?.callSid || "";
// Prefer token from start message customParameters (set via TwiML <Parameter>),
// falling back to query string token. Twilio strips query params from WebSocket
// URLs but reliably delivers <Parameter> values in customParameters.
const effectiveToken = message.start?.customParameters?.token ?? streamToken;
console.log(`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`); console.log(`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`);
if (!callSid) { if (!callSid) {
console.warn("[MediaStream] Missing callSid; closing stream"); console.warn("[MediaStream] Missing callSid; closing stream");
@@ -154,7 +159,7 @@ export class MediaStreamHandler {
} }
if ( if (
this.config.shouldAcceptStream && this.config.shouldAcceptStream &&
!this.config.shouldAcceptStream({ callId: callSid, streamSid, token: streamToken }) !this.config.shouldAcceptStream({ callId: callSid, streamSid, token: effectiveToken })
) { ) {
console.warn(`[MediaStream] Rejecting stream for unknown call: ${callSid}`); console.warn(`[MediaStream] Rejecting stream for unknown call: ${callSid}`);
ws.close(1008, "Unknown call"); ws.close(1008, "Unknown call");
@@ -393,6 +398,7 @@ interface TwilioMediaMessage {
accountSid: string; accountSid: string;
callSid: string; callSid: string;
tracks: string[]; tracks: string[];
customParameters?: Record<string, string>;
mediaFormat: { mediaFormat: {
encoding: string; encoding: string;
sampleRate: number; sampleRate: number;

View File

@@ -429,10 +429,21 @@ export class TwilioProvider implements VoiceCallProvider {
* @param streamUrl - WebSocket URL (wss://...) for the media stream * @param streamUrl - WebSocket URL (wss://...) for the media stream
*/ */
getStreamConnectXml(streamUrl: string): string { getStreamConnectXml(streamUrl: string): string {
// Extract token from URL and pass via <Parameter> instead of query string.
// Twilio strips query params from WebSocket URLs, but delivers <Parameter>
// values in the "start" message's customParameters field.
const parsed = new URL(streamUrl);
const token = parsed.searchParams.get("token");
parsed.searchParams.delete("token");
const cleanUrl = parsed.toString();
const paramXml = token ? `\n <Parameter name="token" value="${escapeXml(token)}" />` : "";
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<Response> <Response>
<Connect> <Connect>
<Stream url="${escapeXml(streamUrl)}" /> <Stream url="${escapeXml(cleanUrl)}">${paramXml}
</Stream>
</Connect> </Connect>
</Response>`; </Response>`;
} }