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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user