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 deget_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
| Escenario | Qué pasa |
|---|---|
| Agent 2.1.0 vinculado a SaaS Fase 1-2 PROD | Cliente manda auth; servidor verifica → sesión abierta. |
| Agent 2.1.0 pre-activación | auth_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 / offline | localAuthToken es null → WS cierra 4401 → usuario alerta. |
Seguridad
- Time-constant comparison:
verify_local_token()usahmac.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]]