Servicio de autenticación local del Agent (`local_token`)
{“tags”: [“terminal”, “agent”, “auth”, “security”, “token-bearer”, “DPAPI”, “middleware”, “websocket”, “bootstrap”, “audit-suprema”], “sources”: [{“type”: “code”, “ref”: “terminal/agent/core/local_token.py”, “last_seen”: “2026-09-01”}, {“type”: “commit”, “ref”: “da790cc”, “last_seen”: “2026-06-11”}, {“type”: “commit”, “ref”: “2cd63e6e48ab58f3bcf5f3e8c67cecef0778ca09”, “last_seen”: “2026-09-01”}, {“type”: “code”, “ref”: “tests/agent/test_agent_local_auth.py”, “last_seen”: “2026-09-01”}, {“type”: “code”, “ref”: “tests/agent/test_agent_t6_takeover_downgrade.py”, “last_seen”: “2026-09-01”}], “related”: [“feature—terminal—s129-fix-agent-2-1-1-arranque”, “entity—terminal—service—agent-auth-js”, “concept—saas—multi-tenancy”, “concept—terminal—bootstrap-agent”, “decision—20260611—fase-3-auth-local-enforcement-on”, “incident—20260901—auditoria-suprema-2-tanda6-agente-toma-control-downgrade”], “content”: ”## Propósito\n\nGenerar, persistir, validar y gatekeeper un token Bearer opaco (secrets.token_urlsafe(32)) que el Agent .exe usa para autenticarse ante su propia API local (HTTP middleware + WS de consola).\n\nFunción pública: auth_required(auth_manager) -> bool \nUbicación: terminal/agent/core/local_token.py\n\n---\n\n## Arquitectura\n\n### Ciclo de vida del token\n\n1. Generación (_generate_token())\n - Crea string de 32 bytes aleatorios con secrets.token_urlsafe() → ~43 caracteres ASCII\n - Se ejecuta UNA sola vez en la vida del Agent (primera ejecución tras instalación)\n\n2. Persistencia (_load_by_path(), _save_by_path())\n - Almacenamiento: %APPDATA%/CreaRackAgent/local_token.enc\n - Cifrado: DPAPI (Data Protection API de Windows) → vinculado a usuario local + máquina\n - Acceso: solo el usuario que corre el .exe puede descifrarlo\n\n3. Depósito en SaaS (durante handshake de activación)\n - Agent → POST https://crearack.com/api/agent/register con el token\n - SaaS lo almacena (tabla agent_local_tokens o sesión Agent)\n - Frontend lo recupera en agent_auth.js vía GET /info tras login\n\n4. Validación (verify_local_token(candidate))\n - Compara candidate contra el token persistido (timing-safe con hmac.compare_digest)\n - Devuelve True ↔ False\n\n### Gate centralizado: auth_required(auth_manager) -> bool\n\npython\nLOCAL_AUTH_ENFORCE = os.environ.get(\"AGENT_LOCAL_AUTH_ENFORCE\", \"\").strip().lower() in (\"1\", \"true\", \"yes\", \"on\")\n\ndef auth_required(auth_manager) -> bool:\n \"\"\"Exige Bearer local solo con flag activo Y Agent vinculado a SaaS.\n \n Doble condición:\n - LOCAL_AUTH_ENFORCE OFF (default): gate nunca se activa → bootstrap compatible\n - Pre-activación (sin saas_url/agent_id): no hay forma que frontend conozca token\n \"\"\"\n if not LOCAL_AUTH_ENFORCE:\n return False\n try:\n return bool(auth_manager and auth_manager.saas_url and auth_manager.agent_id)\n except Exception:\n pass\n return False\n\n\nFunción: decide si HTTP middleware (terminal/middleware.py) y WS de consola (terminal.py) deben exigir Authorization: Bearer <token>.\n\nDefecto: OFF (compatible con bootstrap). Activación: env var AGENT_LOCAL_AUTH_ENFORCE=1 en validación E2E.\n\n### EXEMPT_PATHS: rutas que ignoran el gate\n\npython\nEXEMPT_PATHS = frozenset(\n {\n \"/\",\n \"/info\",\n \"/health\", \"/check\",\n \"/favicon.ico\",\n \"/agent/role\",\n \"/terminal/ui\",\n \"/metrics/ui\", \"/licenses\", \"/licenses.txt\",\n \"/sentinel/status\", \"/traps/stats\", \"/traps/recent\", \"/metrics/stats\",\n }\n)\n\n\nCambio T6 (01-09-2026, commit 2cd63e6e, PR #483): /saas/* salió entero de EXEMPT_PATHS. Antes vivían aquí /saas/configure, /saas/status, /saas/connect, /saas/disconnect, /saas/reconnect, /saas/setup, /saas/profiles, /saas/register-profile, con el argumento de que “su seguridad la da el JWT del SaaS que transportan”. Falso: auth._decode_jwt no verifica firma, así que cualquier web podía fabricar un JWT y llamar a estas rutas para re-vincular el Agent a un SaaS arbitrario — [[incident—20260901—auditoria-suprema-2-tanda6-agente-toma-control-downgrade]]. Ahora /saas/* pasa por el gate como cualquier otra ruta.\n\nRutas que quedan exentas: solo bootstrap (/, /info, /health, /check, /favicon.ico, /agent/role) y páginas HTML de solo lectura de la UI local (/terminal/ui, /metrics/ui, /licenses, /licenses.txt, /sentinel/status, /traps/stats, /traps/recent, /metrics/stats).\n\nRutas gateadas: /network/*, /cluster/*, /sftp/*, /admin/*, y desde T6 también /saas/* (requieren Bearer local si LOCAL_AUTH_ENFORCE=True).\n\n### Excepción acotada: saas_bootstrap_origin_ok(path, headers, auth_manager) -> bool\n\nÚnica excepción al gate de /saas/*. Existe por un caso real: el botón Reauth de la app llama a POST /saas/setup justo cuando el Agent no ha podido conectar (JWT caducado) y, por tanto, su token local NO está depositado en el SaaS — el frontend no lo tiene y exigir el Bearer aquí dejaría al Agent sin vía de rescate desde la app.\n\nDeja pasar la petición solo si su Origin (que el navegador escribe y no es falsificable desde JS) es el SaaS ya vinculado o un origen de desarrollo local. Fail-closed: sin Origin, con Origin desconocido, o con path fuera de /saas/ → False.\n\n---\n\n## Sub-componentes\n\n### _generate_token() -> str\n- Propósito: crear token aleatorio\n- Entrada: nada\n- Salida: 43 caracteres ASCII (32 bytes en base64url)\n- Fuente de datos: secrets.token_urlsafe()\n- Se ejecuta: una sola vez\n\n### _load_by_path(name, path) -> str | None\n- Propósito: descifrar y cargar token persistido\n- Entrada: nombre del token + ruta (ej. %APPDATA%/CreaRackAgent/local_token.enc)\n- Salida: token descifrado o None si no existe/error descifrado\n- Cifrado: DPAPI (Windows nativo)\n- Efectos colaterales: crea el archivo si no existe\n\n### _save_by_path(name, token, path)\n- Propósito: cifrar y persister token\n- Entrada: nombre + token + ruta\n- Salida: nada\n- Cifrado: DPAPI\n\n### verify_local_token(candidate: str | None) -> bool\n- Propósito: validar que el candidato coincide con el token persistido\n- Entrada: candidate (string Bearer)\n- Salida: True ↔ False\n- Seguridad: hmac.compare_digest (timing-safe)\n- Caché: NO, valida siempre contra disco\n\n### get_local_token() -> str | None\n- Propósito: acceso público al token (para depositarlo en SaaS)\n- Entrada: nada\n- Salida: token o None si pre-activación\n- Fuente de datos: carga desde _AGENT_TOKEN_PATH (%APPDATA%/CreaRackAgent/local_token.enc)\n\n### saas_url_allowed(saas_url, headers, auth_manager) -> bool (T6, 01-09-2026)\n- Propósito: política pura (sin FastAPI) que decide si se admite vincular el Agent a saas_url — sustituye a _validate_saas_url de routes/saas.py, que quedó como wrapper fino sobre esta función.\n- Vive aquí y no en routes/saas.py para poder verificarse en CI: el contenedor del SaaS no tiene fastapi, así que un test que cargara el módulo de rutas se saltaría.\n- Regla: se admite el SaaS ya vinculado, los orígenes de desarrollo local, y —solo mientras el Agent siga SIN vincular— el Origin de quien llama (primera vinculación). Con un SaaS ya vinculado, el Origin deja de ser un permiso.\n- Antes del fix: el Origin entraba SIEMPRE en la lista de permitidos, con lo que la comprobación real era “saas_url == quien llama” — tautológica, y explotable desde cualquier web.\n\n---\n\n## Integración con middleware HTTP\n\nArchivo: terminal/middleware.py (asumido; verifica imports)\n\npython\nfrom terminal.agent.core.local_token import auth_required, verify_local_token, EXEMPT_PATHS, saas_bootstrap_origin_ok\n\nclass LocalTokenMiddleware:\n def __call__(self, scope, receive, send):\n path = scope[\"path\"]\n \n if path in EXEMPT_PATHS:\n # No gateado\n return self.app(scope, receive, send)\n \n if auth_required(auth_manager):\n # Gateado: exigir Bearer, salvo la excepción acotada de /saas/*\n headers = dict(scope.get(\"headers\", []))\n auth_header = headers.get(b\"authorization\", b\"\").decode().strip()\n \n if not auth_header.startswith(\"Bearer \"):\n if saas_bootstrap_origin_ok(path, headers, auth_manager):\n return self.app(scope, receive, send)\n return 401_unauthorized_response()\n \n token = auth_header.split(\" \")[1]\n if not verify_local_token(token) and not saas_bootstrap_origin_ok(path, headers, auth_manager):\n return 401_unauthorized_response()\n \n return self.app(scope, receive, send)\n\n\n---\n\n## Integración con WS de consola\n\nArchivo: terminal.py (asumido)\n\nEn el handshake on_connect del WebSocket de la consola:\n\npython\ndef on_connect(message):\n auth_token = message.get(\"auth_token\")\n \n if auth_required(auth_manager):\n if not auth_token or not verify_local_token(auth_token):\n return ws_close(code=4001, reason=\"Unauthorized\")\n \n # Conectado\n return ws_accept()\n\n\n---\n\n## Variables de entorno\n\n| Variable | Default | Tipo | Propósito |\n|----------|---------|------|-----------|\n| AGENT_LOCAL_AUTH_ENFORCE | off (empty) | \"0\"|\"1\"|\"true\"|\"false\"|\"yes\"|\"no\"|\"on\"|\"off\" | Activar/desactivar gate |\n\n---\n\n## Historias de versión\n\n### Agent 2.1.0 (s128 — Auditoría Suprema, gate inicial)\n- Token Bearer opaco generado + persistido (DPAPI)\n- Middleware HTTP + WS que exigen token EN TODOS LADOS\n- ❌ Problema: rompe bootstrap (EXEMPT_PATHS incompleto, sin flag de enforcement)\n\n### Agent 2.1.1 (s129 — Fix arranque)\n- ✅ Flag AGENT_LOCAL_AUTH_ENFORCE (default OFF)\n- ✅ EXEMPT_PATHS completo (/saas/* + /agent/role)\n- ✅ Gate centralizado en auth_required() (HTTP + WS respetan el flag)\n\n### Agent 2.26.0 (T6 — Auditoría Suprema 2, 01-09-2026, commit 2cd63e6e, PR #483)\n- ❌ Problema encontrado: /saas/* seguía exento entero desde 2.1.1 — cinco años de superficie (en términos del proyecto) confiando en un JWT sin firma verificada + un guard de Origin tautológico (_validate_saas_url). Toma de control completa del Agente desde cualquier web.\n- ✅ /saas/* sale de EXEMPT_PATHS\n- ✅ saas_bootstrap_origin_ok() nueva: excepción acotada por Origin de confianza, preserva el botón Reauth\n- ✅ saas_url_allowed() nueva: sustituye a _validate_saas_url, el Origin deja de ser permiso tras la primera vinculación\n- Detalle completo: [[incident—20260901—auditoria-suprema-2-tanda6-agente-toma-control-downgrade]]\n\n---\n\n## Véase también\n\n- [[feature—terminal—s129-fix-agent-2-1-1-arranque]]\n- [[entity—terminal—service—agent-auth-js]]\n- [[concept—saas—multi-tenancy]]\n- [[concept—terminal—bootstrap-agent]]\n- [[decision—20260611—fase-3-auth-local-enforcement-on]]\n- [[incident—20260901—auditoria-suprema-2-tanda6-agente-toma-control-downgrade]]\n”}