Saltar a contenido

WebSocket

Una ruta WebSocket propia se protege con una dependencia, igual que un endpoint HTTP. No hace falta saber cómo viaja el token ni qué se comprueba: la conexión llega al endpoint ya aceptada y autenticada, con el mismo AccessToken que en HTTP.

En la API

# api/routers/custom/chat.py
from fastapi import APIRouter, Depends, WebSocket

from api.routers.auth import (
    AccessToken,
    websocket_requires_authentication,
    websocket_requires_permissions,
)

router = APIRouter(prefix="/ws", tags=["Tiempo real"])


@router.websocket("/avisos")
async def avisos(websocket: WebSocket, token: AccessToken = Depends(websocket_requires_authentication())):
    await websocket.send_json({"hola": token.preferred_username})
    async for mensaje in websocket.iter_text():
        ...


@router.websocket("/pedidos")
async def pedidos(websocket: WebSocket, token: AccessToken = Depends(websocket_requires_permissions("read", "pedido"))):
    ...
Dependencia Exige Equivale en HTTP a
websocket_requires_authentication() Un usuario autenticado requires_authentication()
websocket_requires_permissions(action, resource) El permiso <resource>-<action> (o sudo, <resource>-admin, allresources-<action>) requires_permissions(action, resource)

Las dos funcionan igual con Database y con Keycloak, y respetan RBAC_ENABLED: con tai-api dev se entra sin token, con el usuario de desarrollo.

No llames a accept()

La dependencia acepta la conexión antes de que empiece el endpoint. Si tu código ya llamaba a websocket.accept() —un gestor de conexiones, por ejemplo— no hace falta quitarlo: la segunda llamada no hace nada.

Con from __future__ import annotations, importa WebSocket arriba del módulo

FastAPI busca la anotación websocket: WebSocket entre los nombres del módulo. Si WebSocket se importa dentro de una función, la toma por un parámetro de la URL y rechaza la conexión.

En el navegador

Con el cliente TypeScript y la sesión con cookie:

import { createTaiClient, shouldReconnect } from '@/lib/api'

const api = createTaiClient()

async function conectar() {
  const ws = await api.session.openWebSocket('/ws/avisos')
  ws.onmessage = (e) => console.log(JSON.parse(e.data))
  ws.onclose = (e) => {
    if (shouldReconnect(e.code)) setTimeout(conectar, 1_000)
  }
}

openWebSocket refresca el token si está a punto de caducar, lo pone en la conexión y resuelve la URL igual que las peticiones (/api detrás del proxy del frontend). taiApiProxy() deja pasar los WebSocket (ws: true).

Sin el cliente, el token va como subprotocolo, detrás de tai.bearer:

new WebSocket('wss://app.cliente.com/api/ws/avisos', ['tai.bearer', accessToken])

Un cliente que no es un navegador puede mandar en su lugar Authorization: Bearer <token>.

Nunca en la URL

?token=... acaba en los logs de acceso de uvicorn y de cualquier proxy que haya delante. La API no lo lee.

Cuando no entra

La conexión se acepta y se cierra con un código. Cerrarla antes de aceptarla no serviría: el navegador solo vería un 1006, sin código ni motivo.

Código Motivo (reason) Qué hacer
4401 UNAUTHORIZED_ACCESS, INVALID_TOKEN, SESSION_INVALIDATED Iniciar sesión otra vez
4419 TOKEN_EXPIRED Reconectar: openWebSocket pide un token vigente
4403 INSUFFICIENT_PERMISSIONS, ORIGIN_NOT_ALLOWED Nada: reconectar no lo arregla
1013 SERVICE_UNAVAILABLE (Keycloak no responde) Reintentar más tarde

shouldReconnect(code) reconecta con 1006, 4419 y 1013. Los códigos que cierre tu propio endpoint (un recurso que no existe, uno que no es del usuario) son tuyos: elige otros del rango 4000–4999 y no los confundas con estos.

El origen

CORS no se aplica a WebSocket, así que la dependencia comprueba la cabecera Origin contra la misma lista, ALLOWED_ORIGINS de config.py:

ALLOWED_ORIGINS Qué se acepta
["*"] Cualquier origen
["https://app.cliente.com"] Esos orígenes y el de la propia API
[] (sesión con cookie detrás del proxy /api) El mismo origen, también el que llega en X-Forwarded-Host, o PUBLIC_URL

Una conexión sin Origin no viene de un navegador y no se comprueba.

Lo que no hace (todavía)

  • Revalidar durante la conexión. El token se comprueba al conectar. Si la sesión se cierra después —logout, otra sesión, un cambio de contraseña—, la conexión abierta sigue hasta que se cierre. Si el endpoint vive mucho, ciérralo tú periódicamente con 4419: el cliente reconectará con un token vigente, y si la sesión ya no existe, recibirá 4401.