CORS en señalización: por qué falla en la tele y nunca en la app nativa
La misma API de ChronoFlow atiende a una app Flutter en teléfonos, tablets y Android TV, al dashboard web y a las apps de las teles Samsung y LG. Casi todo funciona igual para todos, con una excepción: CORS. En el teléfono nunca da problemas; en la tele, una cabecera nueva basta para que el Player deje de funcionar sin un solo error en la API. Este artículo cuenta por qué pasa y qué reglas seguimos.
Quién envía un Origin y quién no
CORS es una regla de los navegadores, no de HTTP. Cuando código JavaScript de una página intenta llamar a otro origen, el navegador pregunta antes al servidor si lo permite, y si la respuesta no le convence, bloquea la llamada. El servidor ni se entera de que la petición real existía.
- La app Flutter habla con la API mediante un cliente HTTP nativo (Dio). No hay navegador, no se envía cabecera
Originy no hay petición previa. CORS no aplica. Tampoco tenemos versión web de la app Flutter, así que esto no cambia. - El dashboard web se sirve desde el mismo dominio que la API. Para el navegador es el mismo origen, así que tampoco hay CORS.
- Las apps de Samsung (Tizen) y LG (webOS) son el Player web en Vue, empaquetado. Corren en el motor de navegador de la tele y se cargan desde archivos locales del paquete, no desde un dominio. Para ellas, cada llamada a la API es de otro origen, y ahí CORS aplica en todo su rigor.
Origin: null
Una app de tele empaquetada no tiene un dominio propio, así que el motor envía Origin: null. La tentación es añadir null a la lista de orígenes permitidos y seguir. No lo hacemos: también envían null los archivos abiertos desde el disco, los iframes aislados y algunas redirecciones, así que permitir null equivale a permitir cualquiera de ellos.
Lo importante es que CORS no es un mecanismo de autenticación. Decide qué páginas pueden leer una respuesta desde un navegador, no quién puede hablar con la API. En ChronoFlow lo que autentica es un token bearer que viaja en la cabecera Authorization. No usamos cookies, así que una página ajena no tiene ninguna sesión que aprovechar aunque consiga hacer una petición.
La petición previa, antes que la autenticación
Casi todas las llamadas del Player necesitan una petición previa OPTIONS: llevan Authorization, envían JSON y, al emparejar, una cabecera propia con el secreto del dispositivo. El navegador pregunta si esos métodos y cabeceras están permitidos y solo después envía la petición real.
Esa petición previa nunca lleva el token, así que tiene que responderse antes de cualquier comprobación de autenticación. En Laravel lo hace el middleware global de CORS, que contesta con un 204 y la lista de métodos y cabeceras permitidos. La respuesta se puede cachear hasta 24 horas, aunque cada motor aplica su propio máximo, más bajo, así que no conviene contar con ello.
Cada cabecera nueva, a la lista
Esta es la trampa más fácil de pisar. Cuando reforzamos el emparejamiento, el Player empezó a enviar su secreto en una cabecera nueva, X-Device-Secret. Si esa cabecera no está en la lista de permitidas de la configuración de CORS, el navegador de la tele cancela la petición tras la petición previa. La API no registra ningún error porque la petición real nunca llega, y en la app nativa todo funciona, lo que despista todavía más. Por eso el mismo cambio que introdujo la cabecera la añadió a la lista, y es una regla del proyecto: una cabecera propia nueva no está terminada hasta que está en la configuración de CORS.
Lo mismo pasa en sentido contrario. JavaScript solo puede leer unas pocas cabeceras de la respuesta; el resto hay que exponerlas explícitamente. La API expone X-API-Version para que el Player pueda saber con qué versión está hablando.
Los buckets también tienen CORS
Los archivos no se sirven desde la API sino desde el almacenamiento de objetos, con URLs firmadas (lo contamos en almacenamiento de objetos por plan). Los buckets tienen sus propias reglas de CORS, versionadas en el repositorio junto con los scripts que las aplican. Subir archivos está limitado al origen del dashboard de cada entorno. Leerlos está abierto a cualquier origen, porque los Players de tele necesitan descargar los archivos para su caché sin conexión y no tienen un origen estable. Los buckets siguen siendo privados: sin una URL firmada no se lee nada.
Una base de código, tres destinos
El dashboard y el Player de tele salen del mismo código Vue, compilado con Vite en tres modos: web, Tizen y webOS. Los modos de tele usan rutas relativas y un enrutador por hash, porque en un paquete no hay servidor que resuelva URLs. El script de empaquetado se niega a generar el paquete si la URL de la API no es absoluta y HTTPS, si algún recurso usa una ruta absoluta desde la raíz, o si se ha colado un archivo de entorno o una clave.
Algunas cosas que en la web se deducen de la página hay que fijarlas al compilar. El host del WebSocket en tiempo real es un ejemplo: en el navegador se puede derivar del dominio, pero un paquete no tiene dominio, así que el pipeline de publicación lo inyecta en cada compilación de tele.
Nada que dependa de terceros
Cada recurso remoto es un punto de fallo más en una tele que puede estar detrás de una red restrictiva, y otra conversación con CORS. Por eso el código del Player de tele no se carga de ningún CDN: todo va dentro del paquete. Por ejemplo, el QR del emparejamiento se dibuja en el propio aparato (lo contamos en emparejar pantallas con un código y un QR).
El motor de la tele no es tu navegador
Probando en un monitor Samsung Odyssey G6 con Tizen 6.5 aprendimos que el motor web de una tele va por detrás de un navegador de escritorio y tiene sus propias ideas. El foco del D-pad no se veía con los estilos que funcionaban en Chrome, así que el foco para mando tiene su propio módulo con pruebas. El Player no llenaba la pantalla hasta ajustar el diseño a la ventana de la tele. Nada de eso aparece en un navegador de escritorio: hay que probar en el aparato.
Lo que descartamos
- Permitir
Origin: null. Abre la puerta a cualquier página local o aislada. - Sesiones con cookies en la tele. Añaden CSRF y reglas de cookies de terceros a un entorno que ya es difícil, sin aportar nada frente a un token.
- Cargar librerías desde un CDN. Un fallo externo dejaría las pantallas en negro.
Para seguir leyendo
Por qué hay un Player en Vue y otro en Flutter: Controller y Player en una sola app. Cómo se autentican los clientes: OAuth2 con Passport para web, móvil y TV. El producto: ChronoFlow. Si tu producto también tiene que funcionar en teles: Servicios.