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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 19 additions and 2 deletions

View File

@ -146,6 +146,11 @@ export class MediaStreamHandler {
const streamSid = message.streamSid || "";
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})`);
if (!callSid) {
console.warn("[MediaStream] Missing callSid; closing stream");
@ -154,7 +159,7 @@ export class MediaStreamHandler {
}
if (
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}`);
ws.close(1008, "Unknown call");
@ -393,6 +398,7 @@ interface TwilioMediaMessage {
accountSid: string;
callSid: string;
tracks: string[];
customParameters?: Record<string, string>;
mediaFormat: {
encoding: string;
sampleRate: number;

View File

@ -429,10 +429,21 @@ export class TwilioProvider implements VoiceCallProvider {
* @param streamUrl - WebSocket URL (wss://...) for the media stream
*/
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"?>
<Response>
<Connect>
<Stream url="${escapeXml(streamUrl)}" />
<Stream url="${escapeXml(cleanUrl)}">${paramXml}
</Stream>
</Connect>
</Response>`;
}