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
+6 -1
View File
@@ -522,13 +522,18 @@ class CloudConnector:
panel_scope,
snapshot_payload,
)
from fluksio.api.socket_auth import redeem_ticket
from fluksio.core.db import engine
from fluksio.flow.events import event_bus
stream_id = str(frame["id"])
path = str(frame.get("path") or "")
query = str(frame.get("query") or "")
token = _query_value(query, "token")
# The browser's ticket when it brought one; the hub restates the
# session it checked as `token` either way, which is the tunnel's own
# channel and stays when the public `?token=` goes.
ticket = _query_value(query, "ticket")
token = redeem_ticket(ticket) if ticket else _query_value(query, "token")
if not path.startswith(ALLOWED_PREFIX) or not path.endswith("/flows/ws"):
await socket.send(_dump({"op": "ws_close", "id": stream_id, "code": 1008}))
+9 -5
View File
@@ -27,15 +27,15 @@ BACKOFF = (1.0, 5.0, 30.0, 60.0)
POLL_S = 1.0
def socket_url(url: str, token: str) -> str:
def socket_url(url: str) -> str:
"""The websocket beside an engine's HTTP address.
The token goes in the query string because a handshake carries no headers
of its own — the same reason the browser sends it that way.
The token is not in it: it goes as a header on the handshake, since a URL
ends up in access logs.
"""
parts = urlsplit(url)
scheme = "wss" if parts.scheme == "https" else "ws"
return urlunsplit((scheme, parts.netloc, "/api/v1/flows/ws", f"token={token}", ""))
return urlunsplit((scheme, parts.netloc, "/api/v1/flows/ws", "", ""))
def subscribe(
@@ -61,7 +61,11 @@ def subscribe(
attempt = 0
while not stop.is_set():
try:
with connect(socket_url(url, token), open_timeout=POLL_S * 5) as socket:
with connect(
socket_url(url),
additional_headers={"Authorization": f"Bearer {token}"},
open_timeout=POLL_S * 5,
) as socket:
attempt = 0
while not stop.is_set():
try:
+1
View File
@@ -521,6 +521,7 @@ async def test_the_live_socket_ends_when_its_client_goes(
def __init__(self) -> None:
self.app = client.app
self.headers: dict[str, str] = {}
self.frames = ['{"type": "media", "names": []}']
self.sent: list[str] = []
+12
View File
@@ -892,6 +892,18 @@ def test_a_panels_socket_carries_only_what_it_draws(
# A person's credential is not bounded at all.
assert panel_scope(superuser_token_headers["Authorization"][7:], client.app) is None
# It opens the socket with a ticket like any browser, and the ticket
# carries the panel's bound rather than widening it.
ticket = client.post(
f"{settings.API_V1_STR}/flows/ws-ticket",
headers={"Authorization": f"Bearer {token}"},
)
assert ticket.status_code == 200, ticket.text
with client.websocket_connect(
f"{settings.API_V1_STR}/flows/ws?ticket={ticket.json()['ticket']}"
) as ws:
assert ws.receive_json()["nodes"] == []
def test_a_panel_fetches_only_the_media_its_tiles_are_showing(
client: TestClient, superuser_token_headers: dict[str, str]
@@ -0,0 +1,72 @@
"""A socket's credential comes as a header or a one-time ticket, not in its URL."""
from time import monotonic
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from fluksio.core.config import settings
from fluksio.flow.remote import PROTOCOL
FLOWS = f"{settings.API_V1_STR}/flows"
ATTACH = f"{settings.API_V1_STR}/workers/attach"
def ticket(client: TestClient, headers: dict[str, str]) -> str:
answer = client.post(f"{FLOWS}/ws-ticket", headers=headers)
assert answer.status_code == 200, answer.text
return str(answer.json()["ticket"])
def opens(client: TestClient, url: str, headers: dict[str, str] | None = None) -> bool:
try:
with client.websocket_connect(url, headers=headers or {}) as ws:
assert ws.receive_json()["type"] == "snapshot"
except WebSocketDisconnect:
return False
return True
def test_the_live_socket_takes_a_header_and_still_a_token(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
assert opens(client, f"{FLOWS}/ws", superuser_token_headers)
token = superuser_token_headers["Authorization"][7:]
assert opens(client, f"{FLOWS}/ws?token={token}")
assert not opens(client, f"{FLOWS}/ws")
def test_a_ticket_opens_the_socket_once(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
once = ticket(client, superuser_token_headers)
assert opens(client, f"{FLOWS}/ws?ticket={once}")
assert not opens(client, f"{FLOWS}/ws?ticket={once}")
def test_a_ticket_lapses_after_thirty_seconds(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
stale = ticket(client, superuser_token_headers)
later = monotonic() + 31
monkeypatch.setattr("fluksio.api.socket_auth.monotonic", lambda: later)
assert not opens(client, f"{FLOWS}/ws?ticket={stale}")
def test_a_worker_attaches_with_a_header_and_still_a_token(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
token = client.post(
f"{settings.API_V1_STR}/workers/tokens",
headers=superuser_token_headers,
json={"name": "header-box"},
).json()["token"]
hello = {"op": "hello", "protocol": PROTOCOL}
bearer = {"Authorization": f"Bearer {token}"}
for url, headers in ((ATTACH, bearer), (f"{ATTACH}?token={token}", {})):
with client.websocket_connect(url, headers=headers) as ws:
ws.send_json(hello)
assert ws.receive_json()["op"] == "welcome"
+3 -5
View File
@@ -1059,11 +1059,9 @@ def test_a_duration_reads_the_way_an_age_does() -> None:
def test_the_dashboard_subscribes_where_the_browser_does() -> None:
"""The same socket, and the token where a handshake can carry it."""
"""The same socket, with the token kept out of its URL."""
from fluksio.sdk.stream import socket_url
assert socket_url("http://127.0.0.1:8000", "abc") == (
"ws://127.0.0.1:8000/api/v1/flows/ws?token=abc"
)
assert socket_url("http://127.0.0.1:8000") == "ws://127.0.0.1:8000/api/v1/flows/ws"
# TLS on the one side is TLS on the other.
assert socket_url("https://engine.example.com", "t").startswith("wss://")
assert socket_url("https://engine.example.com").startswith("wss://")
+32
View File
@@ -579,3 +579,35 @@ def test_a_member_the_portal_vouches_for_is_adopted(
"fluksio.cloud.enroll.httpx.get", side_effect=httpx.ConnectError("no route")
):
assert user_from_token(db, stranger) is None
def test_the_tunnel_opens_the_live_socket_with_a_ticket(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A browser under the portal brings a ticket, which the engine redeems."""
import json
from fluksio.cloud.connector import CloudConnector
ticket = client.post(
f"{settings.API_V1_STR}/flows/ws-ticket", headers=superuser_token_headers
).json()["ticket"]
connector = CloudConnector(client.app)
frame = {"id": "s1", "path": "/api/v1/flows/ws", "query": f"ticket={ticket}"}
async def opened() -> list[str]:
sent: list[str] = []
class Socket:
async def send(self, text: str) -> None:
sent.append(text)
task = asyncio.create_task(connector._stream(Socket(), frame))
while len(sent) < 1:
await asyncio.sleep(0.01)
task.cancel()
return [str(json.loads(text)["op"]) for text in sent]
assert asyncio.run(opened())[0] == "ws_open_ok"
# Used once: the same ticket again is refused.
assert asyncio.run(opened()) == ["ws_close"]