The embedded-TUI PTY child attaches to two server-internal WebSockets: /api/ws (its primary JSON-RPC gateway backend) and /api/pub (the event sidecar). Both URLs are built server-side in web_server.py and handed to the child via its environment. In OAuth-gated mode (auth_required=true, every hosted Fly agent), _ws_auth_ok unconditionally rejects the legacy ?token=<_SESSION_TOKEN> path — a leaked session token must not grant WS access once the gate is engaged. But _build_gateway_ws_url() still only emitted ?token=, with no gated-mode branch (its sibling _build_sidecar_url had been given a ticket branch; the gateway-url builder was missed). So the TUI child's /api/ws upgrade was rejected 4401 -> 'gateway websocket connection failed' -> 'gateway startup timeout', leaving the embedded chat unusable on every gated deployment. A single-use 30s browser ticket is the wrong shape for this link: the child reads its attach URL once at startup and reuses it on every reconnect, and on a slow cold boot it may not dial within the TTL. (_build_sidecar_url's own docstring already flagged this fragility.) Fix: add a process-lifetime, multi-use internal credential to dashboard_auth.ws_tickets (internal_ws_credential / consume_internal_credential), minted once per process and NEVER injected into the SPA — it only leaves the process via a spawned child's env, so browser-side XSS can't read it, and a leak grants no more than a ticket already does. _ws_auth_ok accepts it via ?internal= in gated mode only. Both _build_gateway_ws_url and _build_sidecar_url now use it, so the child can reconnect both sockets. Loopback / --insecure behavior is unchanged (still ?token=). Needs review: touches _ws_auth_ok + dashboard_auth (core auth surface).
160 lines
6.4 KiB
Python
160 lines
6.4 KiB
Python
"""WS-upgrade auth credentials for gated mode.
|
|
|
|
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
|
|
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
|
|
token is injected into the SPA bundle. In gated mode there is no injected
|
|
token — so this module provides two credential shapes:
|
|
|
|
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
|
|
The SPA gets a fresh ticket via the authenticated REST endpoint
|
|
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
|
|
upgrade. Single-use, TTL = 30 seconds — a leaked ticket is uninteresting.
|
|
|
|
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
|
|
``consume_internal_credential``). This authenticates *server-spawned*
|
|
WS clients — specifically the embedded-TUI PTY child, which attaches to
|
|
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
|
|
loopback. A single-use 30s ticket is the wrong shape for that link: the
|
|
child reads its attach URL once at startup and **reuses it on every
|
|
reconnect**, and on a slow cold boot the child may not dial within 30s.
|
|
The internal credential is minted once per process, never expires, is
|
|
multi-use, and — critically — is **never injected into any HTML/SPA**:
|
|
it only ever leaves the process via the spawned child's environment, so
|
|
browser-side XSS cannot read it. A leaked internal credential grants no
|
|
more than a single-use ticket already does (the same two internal WS
|
|
endpoints), and the same Origin / host guards still apply downstream.
|
|
|
|
In-memory; the dashboard is a single process so no distributed coordination
|
|
is needed. The module exposes a small functional API rather than a class so
|
|
tests can patch ``time.time`` cleanly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import threading
|
|
import time
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
|
|
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
|
|
#: short enough that a leaked ticket is uninteresting.
|
|
TTL_SECONDS = 30
|
|
|
|
_lock = threading.Lock()
|
|
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
|
|
|
|
#: The process-lifetime internal credential (see module docstring). Lazily
|
|
#: minted on first ``internal_ws_credential()`` call and stable for the life
|
|
#: of the process. Guarded by ``_lock``.
|
|
_internal_credential: Optional[str] = None
|
|
|
|
#: Identity recorded for connections that authenticate via the internal
|
|
#: credential, so audit logs distinguish them from browser-initiated tickets.
|
|
INTERNAL_USER_ID = "server-internal"
|
|
INTERNAL_PROVIDER = "server-internal"
|
|
|
|
|
|
class TicketInvalid(Exception):
|
|
"""Ticket missing, expired, or already consumed."""
|
|
|
|
|
|
def mint_ticket(*, user_id: str, provider: str) -> str:
|
|
"""Generate a one-shot ticket bound to this user identity.
|
|
|
|
The returned token is base64url, 43 bytes of entropy (32-byte random
|
|
seed). Stash returns the ``info`` dict to the caller on consume so the
|
|
WS handler can carry the identity forward into its session log.
|
|
"""
|
|
ticket = secrets.token_urlsafe(32)
|
|
info = {
|
|
"user_id": user_id,
|
|
"provider": provider,
|
|
"minted_at": int(time.time()),
|
|
}
|
|
with _lock:
|
|
_tickets[ticket] = (int(time.time()) + TTL_SECONDS, info)
|
|
_gc_expired_locked()
|
|
return ticket
|
|
|
|
|
|
def consume_ticket(ticket: str) -> Dict[str, Any]:
|
|
"""Validate and consume. Raises :class:`TicketInvalid` on missing/expired/used.
|
|
|
|
Single-use semantics: a successful consume immediately removes the
|
|
ticket from the store, so a second call with the same value raises
|
|
``TicketInvalid("unknown ticket: …")``.
|
|
"""
|
|
now = int(time.time())
|
|
with _lock:
|
|
entry = _tickets.pop(ticket, None)
|
|
if entry is None:
|
|
# Truncate ticket value in the error so misuse never logs the
|
|
# secret in full.
|
|
truncated = (ticket[:8] + "…") if ticket else "<empty>"
|
|
raise TicketInvalid(f"unknown ticket: {truncated}")
|
|
expires_at, info = entry
|
|
if expires_at < now:
|
|
raise TicketInvalid("expired")
|
|
return info
|
|
|
|
|
|
def _gc_expired_locked() -> None:
|
|
"""Drop expired tickets. Caller must hold ``_lock``."""
|
|
now = int(time.time())
|
|
expired = [t for t, (exp, _) in _tickets.items() if exp < now]
|
|
for t in expired:
|
|
_tickets.pop(t, None)
|
|
|
|
|
|
def internal_ws_credential() -> str:
|
|
"""Return the process-lifetime internal WS credential, minting it once.
|
|
|
|
Used by the server to authenticate WS clients it spawns itself (the
|
|
embedded-TUI PTY child). The value is stable for the life of the process,
|
|
multi-use, and never expires — so a server-spawned child can reconnect
|
|
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
|
|
|
|
The credential is never injected into the SPA HTML or returned over any
|
|
REST endpoint; it is only ever passed to a child process via its
|
|
environment. See the module docstring for the threat-model rationale.
|
|
"""
|
|
global _internal_credential
|
|
with _lock:
|
|
if _internal_credential is None:
|
|
_internal_credential = secrets.token_urlsafe(32)
|
|
return _internal_credential
|
|
|
|
|
|
def consume_internal_credential(value: str) -> Dict[str, Any]:
|
|
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
|
|
|
|
Unlike :func:`consume_ticket` this is **not** single-use — the value is
|
|
not removed on success, so a server-spawned child can present it on every
|
|
(re)connect. Returns the fixed server-internal identity ``info`` dict so
|
|
the WS handler can carry it into its session log, mirroring the shape
|
|
``consume_ticket`` returns.
|
|
|
|
A constant-time compare against the (lazily-minted) credential avoids
|
|
leaking length / prefix information on mismatch. If no internal
|
|
credential has been minted yet, any value is rejected.
|
|
"""
|
|
with _lock:
|
|
expected = _internal_credential
|
|
if not value or expected is None:
|
|
raise TicketInvalid("no internal credential")
|
|
if not secrets.compare_digest(value.encode(), expected.encode()):
|
|
raise TicketInvalid("internal credential mismatch")
|
|
return {
|
|
"user_id": INTERNAL_USER_ID,
|
|
"provider": INTERNAL_PROVIDER,
|
|
}
|
|
|
|
|
|
def _reset_for_tests() -> None:
|
|
"""Test-only: drop all tickets and the internal credential."""
|
|
global _internal_credential
|
|
with _lock:
|
|
_tickets.clear()
|
|
_internal_credential = None
|