Authenticate sockets by header or one-time ticket instead of ?token=

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
2026-10-07 08:58:22 +02:00
co-authored by Claude Opus 5.5
parent eb45fb2763
commit 5259b8db0a
21 changed files with 336 additions and 44 deletions
+3
View File
@@ -198,6 +198,9 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
allowed = "/" not in name and name in panel.dashboards and published
elif method == "GET" and path == f"{api}/panels/{panel.id}":
allowed = True
elif method == "POST" and path == f"{api}/flows/ws-ticket":
# How its live socket opens; the socket applies the panel's own scope.
allowed = True
elif path.startswith(f"{api}/messages/"):
# The two a widget speaks: a chart reads a series, a control puts a
# value in. Percent-decoded, because a message name is a path segment
+25 -5
View File
@@ -23,11 +23,13 @@ from fluksio.api.deps import (
CurrentUser,
FlowControllerDep,
SessionDep,
TokenDep,
decode_token,
get_code_author,
get_current_user,
user_from_token,
)
from fluksio.api.socket_auth import TICKET_TTL_S, issue_ticket, socket_token
from fluksio.core.db import engine
from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import NODE_TYPES, FlowController
@@ -66,8 +68,8 @@ from fluksio.models import Flavor, Message, Run
router = APIRouter(
prefix="/flows", tags=["flows"], dependencies=[Depends(get_current_user)]
)
# The websocket authenticates from its query string, so it stays off the
# router that enforces the HTTP bearer scheme.
# The websocket authenticates from its handshake, a header or a ticket, so it
# stays off the router that enforces the HTTP bearer scheme.
ws_router = APIRouter(prefix="/flows", tags=["flows"])
@@ -1061,13 +1063,31 @@ async def _send(websocket: WebSocket, events: list[dict[str, Any]]) -> None:
await websocket.send_text(orjson.dumps(payload).decode())
class SocketTicket(BaseModel):
ticket: str
expires_in: float
@router.post("/ws-ticket", response_model=SocketTicket)
def issue_socket_ticket(token: TokenDep) -> Any:
"""A one-time pass for opening `/flows/ws`, good for thirty seconds.
A browser cannot set a header on a websocket handshake, and a token in the
URL ends up in every access log on the way. The ticket goes there instead
and stands for the bearer token of this call, panel credentials included.
"""
return SocketTicket(ticket=issue_ticket(token), expires_in=TICKET_TTL_S)
@ws_router.websocket("/ws")
async def flow_events(websocket: WebSocket, token: str = "") -> None:
async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -> None:
"""Stream values, node status and execution events as they happen.
The token goes in the query string because browsers cannot set headers on
a websocket handshake.
A browser brings a ticket from `POST /flows/ws-ticket`; anything that can
set headers sends `Authorization: Bearer`. `?token=` is still read for one
release, for clients from before either.
"""
token = socket_token(websocket.headers, ticket, token)
# On a thread: authenticating is a database round trip, and the snapshot
# below reads the whole of state. Neither belongs on the event loop, which
+5 -2
View File
@@ -35,6 +35,7 @@ from fluksio.api.deps import (
get_current_user,
worker_claims,
)
from fluksio.api.socket_auth import socket_token
from fluksio.core import security
from fluksio.flow.remote import (
PROTOCOL,
@@ -240,9 +241,11 @@ def read_runtime() -> str:
async def attach(websocket: WebSocket, token: str = "") -> None:
"""A worker's connection, for as long as it holds.
The token goes in the query string for the same reason the dashboard's
does: a websocket handshake carries no headers of its own.
The token comes as `Authorization: Bearer` on the handshake. `?token=` is
still read for one release, so the engine can be upgraded before its
workers.
"""
token = socket_token(websocket.headers, token=token)
try:
claims = await run_in_threadpool(worker_claims, token)
except InvalidTokenError:
+60
View File
@@ -0,0 +1,60 @@
"""Where a websocket's credential comes from, and how it stays out of the URL.
A client that can set headers sends `Authorization: Bearer` on the handshake:
the worker and the SDK. A browser cannot, so it trades its token for a ticket
over an authenticated REST call and brings the ticket instead. A ticket is good
once, for thirty seconds, so the URL every proxy logs is worth nothing after.
The engine is one process, so the tickets live in its memory: the tunnel
connector, which serves the live socket inline, redeems them from the same
place.
"""
from __future__ import annotations
import secrets
import threading
from collections.abc import Mapping
from time import monotonic
#: How long a ticket waits to be used: the length of one connect.
TICKET_TTL_S = 30.0
_tickets: dict[str, tuple[str, float]] = {}
_lock = threading.Lock()
def issue_ticket(token: str) -> str:
"""A one-time ticket standing in for *token*."""
ticket = secrets.token_urlsafe(32)
now = monotonic()
with _lock:
# Swept here rather than on a timer: issuing is what fills the table.
for stale in [key for key, (_, due) in _tickets.items() if due <= now]:
del _tickets[stale]
_tickets[ticket] = (token, now + TICKET_TTL_S)
return ticket
def redeem_ticket(ticket: str) -> str:
"""The token a ticket stands for, once; empty if it is unknown or stale."""
with _lock:
entry = _tickets.pop(ticket, None)
if entry is None or entry[1] <= monotonic():
return ""
return entry[0]
def socket_token(headers: Mapping[str, str], ticket: str = "", token: str = "") -> str:
"""The credential a handshake carries: a header, then a ticket, then a token.
`token` is the query parameter clients sent before the other two existed.
It is read for one more release so an engine can be upgraded before its
workers; remove it after that (see NOTEPAD).
"""
header = headers.get("authorization") or ""
if header.lower().startswith("bearer "):
return header[7:]
if ticket:
return redeem_ticket(ticket)
return token