CreaRack-SL

Servicio terminal_ws_auth · Validación de token en WebSocket de consola

Resumen

Validación de token en el WebSocket de consola (/ws/terminal/{client_id}, Fase 3). El primer mensaje enviado al WebSocket debe ser {"action": "auth", "token": <local_token>}. Sin token válido → cierre inmediato con código 4401.

Implementado en terminal/agent/routes/terminal.py (+31 líneas) y terminal/agent/assets/terminal.html (+10 líneas).


Flujo en el servidor

En terminal_endpoint() de FastAPI:

@router.websocket("/ws/terminal/{client_id}")
async def terminal_endpoint(websocket: WebSocket, client_id: str):
    """WebSocket endpoint for terminal communication.

    Fase 3 (auth local): con el Agent vinculado a un SaaS, el PRIMER mensaje
    debe ser ``{"action": "auth", "token": <local_token>}`` (la UI lo recibe del
    SaaS vía postMessage SET_AUTH_TOKEN, Fase 2). Sin token válido → close 4401.
    """
    await websocket.accept()

    if getattr(sys, "frozen", False):
        from core.auth import get_auth_manager
        from core.local_token import auth_required, verify_local_token
    else:
        from ..core.auth import get_auth_manager
        from ..core.local_token import auth_required, verify_local_token

    # Validación: solo si el Agent está vinculado a un SaaS
    if auth_required(get_auth_manager()):
        try:
            first = json.loads(await websocket.receive_text())
        except Exception:
            first = {}
        
        # Esperamos acción 'auth' + token válido
        if first.get("action") != "auth" or not verify_local_token(first.get("token")):
            logger.warning(f"WS terminal {client_id}: rejected (missing/invalid local token)")
            await websocket.close(code=4401)
            return

    # ... resto de la lógica (terminal.py sin cambios)
    if client_id in ACTIVE_BRIDGES:
        # ... setup bridge SSH, etc.

Mensaje de autenticación

Format

{
  "action": "auth",
  "token": "<TOKEN_OPACO_EJEMPLO_43-44_CHARS>"
}
  • Campo action: obligatorio, debe ser "auth".
  • Campo token: obligatorio, string opaco del tamaño de get_or_create_local_token().

Verificación

first.get("action") != "auth" or not verify_local_token(first.get("token"))

Si alguna de estas condiciones es verdadera, cierra con 4401:

  • El action no es exactamente "auth".
  • El token no pasa verify_local_token() (tiempo constante).

Código del cliente (terminal.html)

En el iframe (terminal.html, ejecutado en el SaaS Fase 2):

// Variable global: token recibido del parent (SaaS)
let localAuthToken = null;

// Listener postMessage: el SaaS inyecta el token
window.addEventListener('message', (event) => {
    const msg = event.data;
    if (msg.type === 'SET_AUTH_TOKEN') {
        localAuthToken = msg.token || null;
        sendWsAuth(ws);  // Reenvía al servidor si WS ya abierto
    }
    // ... otros mensajes (CONNECT_SSH, etc.)
});

// Función para enviar auth al servidor
function sendWsAuth(socket) {
    if (localAuthToken && socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ action: 'auth', token: localAuthToken }));
    }
}

// Setup WebSocket
let ws = new WebSocket(wsUrl);

function setupWs(socket) {
    socket.onopen = () => {
        isDisconnected = false;
        sendWsAuth(socket);  // ← Envía auth como PRIMER mensaje
        if (window.opener || window.parent) {
            window.parent.postMessage({type: 'AGENT_READY', version: "{version}"}, '*');
        }
    };
    
    socket.onmessage = (event) => {
        // ... procesa comandos del servidor
    };
}

Timing y secuencia

1. SaaS (Fase 2) obtiene token del endpoint /api/agent/local-token:
   → Requiere sesión del tenant dueño del Agent
   → Descifra token custodiado (depositado en Fase 3)

2. SaaS envía al iframe (terminal.html):
   window.postMessage({type: 'SET_AUTH_TOKEN', token: '...'}, target_origin)

3. terminal.html recibe postMessage:
   → Cachea en localAuthToken
   → Si WS ya abierto, reenvía inmediatamente
   → Si WS aún cerrado, cachea para el onopen

4. terminal.html abre WebSocket:
   ws.onopen():
   → sendWsAuth(ws) → socket.send({"action":"auth","token":"..."})

5. Servidor recibe primer mensaje:
   → Verifica action == "auth" + verify_local_token()
   → Si OK: continua con la sesión terminal
   → Si fallo: close(4401)

Reconexión

Si la conexión WS se cae y terminal.html reconecta automáticamente:

// WS reconnect (no explícito en el código, pero podría haber si needed)
ws.onclose = () => {
    // ... reconectar después de backoff
};

// Al reconectar, setupWs() se llama de nuevo:
function setupWs(socket) {
    socket.onopen = () => {
        sendWsAuth(socket);  // ← Auth de nuevo, primer mensaje
    };
}

El token sigue cacheado en localAuthToken, por lo que no necesita una nueva postMessage.


Códigos HTTP / WS

  • Cierre 4401: Autenticación fallida en WebSocket. No es standard RFC 6455 (que usa 1000-4999), pero se elige 4401 para paralelismo con HTTP 401 (Unauthorized).
  • Continuación (1000): Close normal después de que la sesión termina.

Compatibilidad

EscenarioQué pasa
Agent 2.1.0 vinculado a SaaS Fase 1-2 PRODCliente manda auth; servidor verifica → sesión abierta.
Agent 2.1.0 pre-activaciónauth_required() = False → WS no exige auth.
Agent 2.0.24 (viejo)Cliente no sabe de auth; servidor no lo exige (pre-activación) → compat.
Browser sin SaaS / offlinelocalAuthToken es null → WS cierra 4401 → usuario alerta.

Seguridad

  • Time-constant comparison: verify_local_token() usa hmac.compare_digest().
  • Rate limiting: Un cierre 4401 no reintenta automáticamente en el cliente (depende del usuario). Podría añadirse backoff exponencial si hubiera problemas.
  • CSRF: El token solo es válido para quien lo presenta en el WS; no se reutiliza en HTTP (que usa Bearer en headers, protegido de CSRF por SOP).

Observabilidad

  • Log warning (fallo): "WS terminal {client_id}: rejected (missing/invalid local token)" — importante para auditar intentos no autorizados.
  • Métrica (opcional): Contar cierre 4401 para detectar configuraciones erradas (SaaS sin token, etc.).

Véase también

  • [[feature—agent—fase-3-auth-local]]
  • [[entity—agent—service—local-token]]
  • [[entity—agent—service—connector-register-local-token]]
  • [[concept—security—websocket-handshake]]