fix(client): send Authorization bearer token on WS upgrade (closes #1395)

SensingClient now accepts a `token=` argument, defaulting to the
RUVIEW_API_TOKEN env var, and sets `Authorization: Bearer <token>`
directly on the WebSocket upgrade — no browser-style ticket workaround,
since Python can set the header on the handshake. Empty/unset token
means no auth (auth-disabled path unchanged).

websockets version-compat: the header keyword was renamed in the
`>=12.0` range this package pins (`extra_headers` <= 13,
`additional_headers` >= 14). Resolved by runtime detection —
`_select_header_kwarg()` inspects `websockets.connect`'s signature and
passes the correct keyword — rather than raising the floor to `>=14`.
Chosen deliberately so existing users on older websockets are not forced
to upgrade; keeps the `websockets>=12.0` pin valid.

Scope: only ws.py connects to the auth-enabled sensing-server. mqtt.py
already supports username/password (its own auth convention); ha.py and
primitives.py do no network I/O. OAuth (ADR-271) is out of scope.

Tests: constructor token, env-var token, constructor-overrides-env,
no-token/empty-token (no header), a parametrized test across both
websockets kwarg conventions, and an end-to-end test asserting the
bearer reaches the in-process server's upgrade request.
This commit is contained in:
ruv
2026-07-22 07:20:32 -07:00
parent 6fd185f8ce
commit 408caf35fa
2 changed files with 223 additions and 2 deletions
+176
View File
@@ -65,7 +65,29 @@ _FIXTURE_MESSAGES = [
]
#: Upgrade-request headers captured by the in-process server, so tests
#: can assert what the client actually put on the handshake.
_CAPTURED_UPGRADE_HEADERS: dict[str, str] = {}
def _upgrade_headers(websocket: Any) -> Any:
"""Read the handshake request headers across websockets versions
(`websocket.request.headers` >= 14, `websocket.request_headers` <= 13)."""
req = getattr(websocket, "request", None)
if req is not None and getattr(req, "headers", None) is not None:
return req.headers
return getattr(websocket, "request_headers", {})
async def _handler(websocket: Any) -> None:
_CAPTURED_UPGRADE_HEADERS.clear()
try:
headers = _upgrade_headers(websocket)
auth = headers.get("Authorization") if hasattr(headers, "get") else None
if auth is not None:
_CAPTURED_UPGRADE_HEADERS["Authorization"] = auth
except Exception:
pass
for msg in _FIXTURE_MESSAGES:
await websocket.send(json.dumps(msg))
# Send one malformed frame to assert the client logs+drops it
@@ -174,6 +196,160 @@ def test_sensing_client_decoder_directly() -> None:
assert msg.rssi is None
# ─── Auth: bearer token on the WS upgrade (issue #1395) ──────────────
def _auth_header_from_kwargs(kwargs: dict) -> Any:
"""Pull the Authorization value out of whichever header kwarg the
installed `websockets` uses (`additional_headers` >= 14,
`extra_headers` <= 13). Returns None if no header kwarg was passed."""
for key in ("additional_headers", "extra_headers"):
if key in kwargs:
return dict(kwargs[key]).get("Authorization")
return None
class _DummyWS:
async def close(self) -> None:
pass
class _CapturingConnect:
"""Stand-in for `websockets.connect` that records the kwargs it was
called with and returns an awaitable yielding a dummy connection."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []
def __call__(self, url: str, **kwargs: Any) -> Any:
self.calls.append((url, kwargs))
async def _coro() -> _DummyWS:
return _DummyWS()
return _coro()
@property
def last_kwargs(self) -> dict:
return self.calls[-1][1]
async def test_token_from_constructor_sets_auth_header(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
async with SensingClient("ws://x/ws/sensing", token="tok-abc"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer tok-abc"
async def test_token_from_env_sets_auth_header(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok-123")
async with SensingClient("ws://x/ws/sensing"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer env-tok-123"
async def test_constructor_token_overrides_env(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok")
async with SensingClient("ws://x/ws/sensing", token="ctor-tok"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer ctor-tok"
async def test_no_token_sends_no_auth_header(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.delenv("RUVIEW_API_TOKEN", raising=False)
async with SensingClient("ws://x/ws/sensing"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) is None
# Auth-disabled path must not smuggle either header kwarg in.
assert "additional_headers" not in fake.last_kwargs
assert "extra_headers" not in fake.last_kwargs
async def test_empty_token_sends_no_auth_header(monkeypatch: Any) -> None:
"""An explicitly empty token (or empty env var) means 'no auth'."""
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.setenv("RUVIEW_API_TOKEN", "")
async with SensingClient("ws://x/ws/sensing"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) is None
@pytest.mark.parametrize(
"header_param,expected",
[
("additional_headers", "additional_headers"), # websockets >= 14
("extra_headers", "extra_headers"), # websockets <= 13
],
)
def test_select_header_kwarg_across_websockets_versions(
header_param: str, expected: str
) -> None:
"""Version-compat: the kwarg is chosen by inspecting the installed
`connect` signature, so both the pre-14 (`extra_headers`) and
post-14 (`additional_headers`) conventions resolve correctly without
two websockets installs."""
from wifi_densepose.client.ws import _select_header_kwarg
# Build a fake `connect` whose signature carries only the one kwarg
# the emulated websockets version would expose.
ns: dict = {}
exec(
f"def fake_connect(uri, *, {header_param}=None, ping_interval=None): ...",
ns,
)
assert _select_header_kwarg(ns["fake_connect"]) == expected
def test_select_header_kwarg_matches_installed_websockets() -> None:
"""On whatever `websockets` is actually installed, the chosen kwarg
must be a real parameter of `websockets.connect`."""
import inspect
import websockets
from wifi_densepose.client.ws import _select_header_kwarg
chosen = _select_header_kwarg(websockets.connect)
assert chosen in inspect.signature(websockets.connect).parameters
async def test_auth_header_reaches_server_end_to_end(ws_server: str) -> None:
"""Real in-process server: assert the bearer actually arrives on the
upgrade request (proves the header is wired to the live handshake,
not just the connect kwargs)."""
async with SensingClient(ws_server, token="e2e-token") as client:
await client.recv_one(timeout=2.0)
assert _CAPTURED_UPGRADE_HEADERS.get("Authorization") == "Bearer e2e-token"
def test_sensing_client_decoder_handles_None_subfields() -> None:
"""When the sensing-server explicitly emits null for HR/BR (no
measurement yet), the client should propagate None, not crash."""