Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contributors/emails/ericlewis777@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ericlewis
# PR #62858 salvage via #72635
58 changes: 55 additions & 3 deletions hermes_cli/dashboard_auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""
from __future__ import annotations

import json
import logging
import threading
import time
Expand Down Expand Up @@ -807,7 +808,9 @@ async def api_auth_ws_ticket(request: Request):

The ticket has a 30-second TTL and is single-use. Calling this endpoint
multiple times in quick succession (e.g. one ticket per WS) is the
expected pattern.
expected pattern. A bodyless request intentionally retains the dashboard's
full legacy authority. A mobile audience requests a narrower grant for one
WebSocket connection; it is not a persistent device credential.
"""
sess = getattr(request.state, "session", None)
if sess is None:
Expand All @@ -818,14 +821,63 @@ async def api_auth_ws_ticket(request: Request):
# don't load the ticket store.
from hermes_cli.dashboard_auth.ws_tickets import TTL_SECONDS, mint_ticket

ticket = mint_ticket(user_id=sess.user_id, provider=sess.provider)
body = await request.body()
requested = {}
if body:
try:
requested = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError):
raise HTTPException(status_code=400, detail="Invalid JSON")
if not isinstance(requested, dict):
raise HTTPException(status_code=400, detail="Expected a JSON object")

audience = str(requested.get("audience") or "").strip()
if body and not audience:
detail = (
"audience is required when scopes are requested"
if "scopes" in requested
else "audience is required for a non-empty ticket request"
)
raise HTTPException(status_code=400, detail=detail)
if audience:
from tui_gateway.mobile_contract import (
MOBILE_AUDIENCE,
normalize_mobile_scopes,
)

if audience != MOBILE_AUDIENCE:
raise HTTPException(status_code=400, detail="Unsupported WebSocket audience")
raw_scopes = requested.get("scopes")
if not isinstance(raw_scopes, list):
raise HTTPException(status_code=400, detail="scopes must be an array")
try:
granted_scopes = normalize_mobile_scopes(raw_scopes)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
ticket = mint_ticket(
user_id=sess.user_id,
provider=sess.provider,
audience=audience,
scopes=granted_scopes,
)
else:
# Preserve the existing dashboard contract exactly for bodyless calls.
ticket = mint_ticket(user_id=sess.user_id, provider=sess.provider)
audit_log(
AuditEvent.WS_TICKET_MINTED,
provider=sess.provider,
user_id=sess.user_id,
ip=_client_ip(request),
)
return {"ticket": ticket, "ttl_seconds": TTL_SECONDS}
response = {"ticket": ticket, "ttl_seconds": TTL_SECONDS}
if audience:
response.update(
{
"audience": audience,
"granted_scopes": list(granted_scopes),
}
)
return response


# ---------------------------------------------------------------------------
Expand Down
41 changes: 32 additions & 9 deletions hermes_cli/dashboard_auth/ws_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
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.
1. **Single-use client tickets** (``mint_ticket`` / ``consume_ticket``).
The SPA gets a full-authority legacy ticket from a bodyless authenticated
``POST /api/auth/ws-ticket``. A native client can request a mobile audience
and explicit scopes instead. Both pass ``?ticket=`` on the WS upgrade and
are single-use with TTL = 30 seconds. The scoped ticket narrows one socket;
it is not a persistent device credential or a replacement for the legacy
compatibility path.

2. **A process-lifetime internal credential** (``internal_ws_credential`` /
``consume_internal_credential``). This authenticates *server-spawned*
Expand Down Expand Up @@ -53,23 +56,41 @@
#: credential, so audit logs distinguish them from browser-initiated tickets.
INTERNAL_USER_ID = "server-internal"
INTERNAL_PROVIDER = "server-internal"
LEGACY_AUDIENCE = "dashboard"
LEGACY_SCOPES = ("*",)


class TicketInvalid(Exception):
"""Ticket missing, expired, or already consumed."""


def mint_ticket(*, user_id: str, provider: str) -> str:
def mint_ticket(
*,
user_id: str,
provider: str,
audience: str = LEGACY_AUDIENCE,
scopes: tuple[str, ...] = LEGACY_SCOPES,
) -> 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.
"""
scope_tuple = tuple(scopes)
if audience == "hermes.mobile":
from tui_gateway.mobile_contract import normalize_mobile_scopes

scope_tuple = normalize_mobile_scopes(scope_tuple)
elif audience != LEGACY_AUDIENCE or scope_tuple != LEGACY_SCOPES:
raise ValueError(f"unsupported WebSocket audience or grant: {audience}")

ticket = secrets.token_urlsafe(32)
info = {
"user_id": user_id,
"provider": provider,
"audience": audience,
"scopes": scope_tuple,
"minted_at": int(time.time()),
}
with _lock:
Expand Down Expand Up @@ -132,10 +153,10 @@ def consume_internal_credential(value: str) -> Dict[str, Any]:
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
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
returns, so a caller that wants to record the connecting identity can; the
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
discards the dict.
(identity plus legacy audience/scopes), mirroring the ``info`` shape
``consume_ticket`` returns so the gateway can carry one effective grant
shape through its transport. Non-gateway callers may validate only the
boolean outcome and discard the dict.

A constant-time compare against the (lazily-minted) credential avoids
leaking length / prefix information on mismatch. If no internal
Expand All @@ -150,6 +171,8 @@ def consume_internal_credential(value: str) -> Dict[str, Any]:
return {
"user_id": INTERNAL_USER_ID,
"provider": INTERNAL_PROVIDER,
"audience": LEGACY_AUDIENCE,
"scopes": LEGACY_SCOPES,
}


Expand Down
66 changes: 49 additions & 17 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14405,24 +14405,29 @@ def _ws_auth_mode() -> str:
return "loopback"


def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
"""Validate WS-upgrade auth; return ``(reason, credential)``.
def _ws_auth_result(
ws: "WebSocket",
) -> tuple[Optional[str], str, Optional[Dict[str, Any]]]:
"""Validate WS-upgrade auth and return its effective authorization grant.

``reason`` is None when the credential is accepted, else a short
machine-parseable token explaining the rejection (``no_credential``,
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
``credential`` names which credential type was presented (``ticket``,
``internal``, ``token``, or ``none``) so the accepted path can log *how*
a peer authed, not just that it did.
``authorization`` is the server-derived grant carried by an accepted
credential; it is ``None`` for every rejected request.

Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.

Gated (public bind, no ``--insecure``): one of two credentials —

* ``?ticket=<single-use>`` — a browser-minted, single-use, 30s-TTL ticket
consumed against the dashboard-auth ticket store. This is what the SPA
(and native clients) use.
* ``?ticket=<single-use>`` — a client-minted, single-use, 30s-TTL ticket
consumed against the dashboard-auth ticket store. The bodyless SPA flow
retains legacy dashboard authority. A native mobile ticket is restricted
to ``/api/ws`` and carries explicit scopes into its dispatcher.
* ``?internal=<process-credential>`` — the process-lifetime internal
credential, used only by WS clients the server spawns itself (the
embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It
Expand Down Expand Up @@ -14454,39 +14459,65 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
internal = ws.query_params.get("internal", "")
if internal:
try:
consume_internal_credential(internal)
return None, "internal"
grant = consume_internal_credential(internal)
return None, "internal", grant
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
reason=f"internal: {exc}",
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return "internal_invalid", "internal"
return "internal_invalid", "internal", None

ticket = ws.query_params.get("ticket", "")
if not ticket:
return "no_credential", "none"
return "no_credential", "none", None

try:
consume_ticket(ticket)
return None, "ticket"
grant = consume_ticket(ticket)
if (
grant.get("audience") == "hermes.mobile"
and ws.url.path != "/api/ws"
):
audit_log(
AuditEvent.WS_TICKET_REJECTED,
reason="mobile ticket used outside /api/ws",
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return "ticket_audience_mismatch", "ticket", None
return None, "ticket", grant
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
reason=str(exc),
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return "ticket_invalid", "ticket"
return "ticket_invalid", "ticket", None

token = ws.query_params.get("token", "")
if not token:
return "no_credential", "none"
return "no_credential", "none", None
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
return None, "token"
return "token_mismatch", "token"
return (
None,
"token",
{
"user_id": "loopback",
"provider": "loopback",
"audience": "dashboard",
"scopes": ("*",),
},
)
return "token_mismatch", "token", None


def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
"""Compatibility view of :func:`_ws_auth_result` for non-gateway sockets."""
reason, credential, _authorization = _ws_auth_result(ws)
return reason, credential


def _ws_auth_ok(ws: "WebSocket") -> bool:
Expand Down Expand Up @@ -15577,7 +15608,8 @@ async def gateway_ws(ws: WebSocket) -> None:
await ws.close(code=4403)
return

if not _ws_auth_ok(ws):
auth_reason, _credential, authorization = _ws_auth_result(ws)
if auth_reason is not None:
await ws.close(code=4401)
return

Expand All @@ -15587,7 +15619,7 @@ async def gateway_ws(ws: WebSocket) -> None:

from tui_gateway.ws import handle_ws

await handle_ws(ws)
await handle_ws(ws, authorization=authorization)


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading