Files
app/backend/fluksio/api/socket_auth.py
T

61 lines
2.1 KiB
Python

"""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. New clients should use the Authorization header.
"""
header = headers.get("authorization") or ""
if header.lower().startswith("bearer "):
return header[7:]
if ticket:
return redeem_ticket(ticket)
return token