diff --git a/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts b/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts index b699df818b..a7bdbe88eb 100644 --- a/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts +++ b/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts @@ -39,6 +39,9 @@ const SERVER_SCRIPT = path.join( 'oob_hub_test_server.py' ); +// Must match TEST_CONTROL_TOKEN in oob_hub_test_server.py. +const HUB_TOKEN = 'oob-hub-test-token'; + // Safety net for afterEach cleanup: every hub PID spawned goes in here and comes out once // confirmed exited. If a test's afterEach is ever skipped (a Jest hook-ordering edge case, // not something this file's own control flow can prevent), this file's own `exit` handler @@ -156,6 +159,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke channel = new HeadsetControlChannel({ url: `ws://127.0.0.1:${hub.port}`, + token: HUB_TOKEN, deviceLabel: 'jest-test', onConfig: (config, configVersion) => { configs.push({ config, version: configVersion }); @@ -193,6 +197,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke channel = new HeadsetControlChannel({ url: `ws://127.0.0.1:${hub.port}`, + token: HUB_TOKEN, onConfig: () => {}, }); channel.connect(); @@ -216,6 +221,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke channel = new HeadsetControlChannel({ url: `ws://127.0.0.1:${hub.port}`, + token: HUB_TOKEN, onConfig: () => {}, metricsIntervalMs: 20, getMetricsSnapshot: () => [{ cadence: 'frame', metrics: { StreamingFramerate: 72.5 } }], diff --git a/docs/source/references/oob_teleop_control.rst b/docs/source/references/oob_teleop_control.rst index de86175725..25d69ee59f 100644 --- a/docs/source/references/oob_teleop_control.rst +++ b/docs/source/references/oob_teleop_control.rst @@ -85,14 +85,18 @@ You should see output confirming the hub is running: If the adb automation fails (e.g. headset not paired), you can manually open the client URL on the headset browser with **all three** required query -parameters — ``oobEnable``, ``serverIP``, and ``port``: +parameters — ``oobEnable``, ``serverIP``, and ``port`` — plus the hub's +``controlToken``: .. parsed-literal:: - |web_client_url|\ ?oobEnable=1&serverIP=&port=48322 + |web_client_url|\ ?oobEnable=1&serverIP=&port=48322&controlToken= Replace ```` with the streaming host's LAN IP. The ``port`` must -match the proxy port (default 48322). +match the proxy port (default 48322). ```` is the value of +``CONTROL_TOKEN``, or the generated token printed in the startup banner; the +banner's bookmark shows it as ````, so substitute it before pasting. +Without the token the hub rejects the headset and the client does not retry. .. note:: @@ -107,7 +111,7 @@ self-signed certificate check): .. code-block:: bash - curl -k https://:48322/api/oob/v1/state + curl -k -H "X-Control-Token: " https://:48322/api/oob/v1/state You should see the headset listed under ``"headsets"`` with ``"connected": true``: @@ -143,7 +147,7 @@ configuration overrides via the HTTP config API: .. code-block:: bash - curl -k "https://:48322/api/oob/v1/config?serverIP=&port=48322&codec=av1" + curl -k -H "X-Control-Token: " "https://:48322/api/oob/v1/config?serverIP=&port=48322&codec=av1" See ``GET /api/oob/v1/config`` below for all supported keys. @@ -157,7 +161,7 @@ from a PC to collect them: .. code-block:: bash # Poll every 2 seconds (adjust to taste) - watch -n 2 'curl -sk https://:48322/api/oob/v1/state | python3 -m json.tool' + watch -n 2 'curl -sk -H "X-Control-Token: " https://:48322/api/oob/v1/state | python3 -m json.tool' The ``metricsByCadence`` field on each headset entry will now contain live streaming metrics. @@ -228,7 +232,7 @@ Registration (first message) "payload": { "role": "headset", "deviceLabel": "Quest 3", - "token": "" + "token": "" } } @@ -444,7 +448,7 @@ version. .. code-block:: bash - curl -k https://localhost:48322/api/oob/v1/state + curl -k -H "X-Control-Token: " https://localhost:48322/api/oob/v1/state Example response: @@ -477,7 +481,7 @@ Push config to connected headsets via query parameters: .. code-block:: bash - curl -k "https://localhost:48322/api/oob/v1/config?serverIP=10.0.0.5&port=48322" + curl -k -H "X-Control-Token: " "https://localhost:48322/api/oob/v1/config?serverIP=10.0.0.5&port=48322" Example response: @@ -497,12 +501,18 @@ if not connected). Authentication -------------- -Set ``CONTROL_TOKEN=`` to require a token on all hub operations. -Pass it as: +Every hub operation requires a token. Set ``CONTROL_TOKEN=`` to choose +it; if it is unset or empty, the hub generates a random token for the session and +prints it in the startup banner. Requests without the token, and WebSocket +``register`` frames without it, are rejected. Pass it as: - WebSocket: ``"token"`` field in the ``register`` payload - HTTP: ``?token=`` query parameter or ``X-Control-Token`` header +The headset bookmark carries the token as ``controlToken`` in the URL query, so +the hosting server and any proxy logs can see it. Treat bookmark URLs as +secrets and prefer a client served from the streaming host (``--host-client``). + Web client integration ---------------------- @@ -511,7 +521,7 @@ The web client connects to the hub when the page URL contains .. parsed-literal:: - |web_client_url|\ ?oobEnable=1&serverIP=10.0.0.1&port=48322 + |web_client_url|\ ?oobEnable=1&serverIP=10.0.0.1&port=48322&controlToken= The client builds ``wss://{serverIP}:{port}/oob/v1/ws`` and: @@ -551,7 +561,7 @@ Environment variables and signaling share this socket. In ``--usb-local`` mode ``adb reverse`` maps it to the headset. * - ``CONTROL_TOKEN`` - - Optional auth token for hub access + - Auth token for hub access; a random per-session token is generated if unset * - ``TELEOP_STREAM_SERVER_IP`` - Override the auto-detected LAN IP in hub initial config * - ``TELEOP_PROXY_HOST`` diff --git a/src/python/isaaccapture/cloudxr/oob_teleop_adb.py b/src/python/isaaccapture/cloudxr/oob_teleop_adb.py index 63a5d74077..5bf23f2d00 100644 --- a/src/python/isaaccapture/cloudxr/oob_teleop_adb.py +++ b/src/python/isaaccapture/cloudxr/oob_teleop_adb.py @@ -45,6 +45,7 @@ client_ui_fields_from_env, oob_progress, redact_control_token, + resolve_control_token, resolve_lan_host_for_oob, web_client_base_override_from_env, ) @@ -604,7 +605,7 @@ def build_teleop_url( default_base = default_web_client_origin() web_base = ovr if ovr else default_base - token = os.environ.get("CONTROL_TOKEN") or None + token = resolve_control_token() return build_headset_bookmark_url( web_client_base=web_base, stream_config=stream_cfg, diff --git a/src/python/isaaccapture/cloudxr/oob_teleop_env.py b/src/python/isaaccapture/cloudxr/oob_teleop_env.py index 58d766a351..d4d56a34cb 100644 --- a/src/python/isaaccapture/cloudxr/oob_teleop_env.py +++ b/src/python/isaaccapture/cloudxr/oob_teleop_env.py @@ -9,9 +9,11 @@ import math import os import re +import secrets import socket import subprocess import sys +import threading from importlib.metadata import PackageNotFoundError, version from pathlib import Path from urllib.error import URLError @@ -22,6 +24,35 @@ from .oob_teleop_hub import OOB_WS_PATH log = logging.getLogger("isaaccapture.cloudxr.oob_teleop_env") + +_generated_control_token: str | None = None +_generated_control_token_lock = threading.Lock() + + +def control_token_is_generated() -> bool: + """``True`` when ``CONTROL_TOKEN`` is unset and the hub uses a random token.""" + return not os.environ.get("CONTROL_TOKEN") + + +def resolve_control_token() -> str: + """Return the hub control token: ``CONTROL_TOKEN``, else a per-process random one. + + The hub never runs unauthenticated. Every reader (hub, headset URL, banner) + must call this so they agree; it does not modify ``os.environ``. + """ + global _generated_control_token + # An empty CONTROL_TOKEN counts as unset: it must never mean "no auth". + configured = os.environ.get("CONTROL_TOKEN") + if configured: + return configured + # The proxy runs on a thread, so the lazy generation is lock-guarded to + # keep the hub, headset URL and banner on the same token. + with _generated_control_token_lock: + if _generated_control_token is None: + _generated_control_token = secrets.token_urlsafe(32) + return _generated_control_token + + progress_log = logging.getLogger("isaaccapture.cloudxr.oob_teleop_env.oob-progress") WSS_PROXY_DEFAULT_PORT = 48322 @@ -598,7 +629,7 @@ def print_oob_hub_startup_banner( port = wss_proxy_port() backend_port = usb_backend_port() turn_port = usb_turn_port() - token = os.environ.get("CONTROL_TOKEN") or None + token = resolve_control_token() if not lan_host: lan_host = resolve_lan_host_for_oob() if not usb_local else "127.0.0.1" @@ -729,11 +760,10 @@ def print_oob_hub_startup_banner( ) print(f" State: {primary_base}/api/oob/v1/state") print(f" Config: {primary_base}/api/oob/v1/config?{cfg_q}") - if token: - print() - print( - " CONTROL_TOKEN is set: add ?token=... or header X-Control-Token on OOB HTTP requests." - ) + print() + print(" Add header X-Control-Token (or ?token=...) on OOB HTTP requests.") + if control_token_is_generated(): + print(f" CONTROL_TOKEN was not set; this session's token is: {token}") print(bar) print() diff --git a/src/python/isaaccapture/cloudxr/oob_teleop_hub.py b/src/python/isaaccapture/cloudxr/oob_teleop_hub.py index 0b3a9ca3fb..36f79feae9 100644 --- a/src/python/isaaccapture/cloudxr/oob_teleop_hub.py +++ b/src/python/isaaccapture/cloudxr/oob_teleop_hub.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio +import hmac import json import logging import time @@ -213,10 +214,16 @@ async def wait_for_streaming( await asyncio.sleep(poll_seconds) def check_token(self, token: str | None) -> bool: - """Return True if token satisfies the hub's auth requirement.""" - if not self._token: - return True - return token == self._token + """Return True only if *token* matches the hub's control token. + + Fails closed: a hub built without a token rejects every caller. + """ + # No configured token or a non-string payload value (JSON can carry ints, + # lists) is never a match; compare_digest would raise on non-str input. + if not self._token or not isinstance(token, str): + return False + # Constant-time compare so response timing does not leak the token. + return hmac.compare_digest(token.encode(), self._token.encode()) async def http_oob_set_config(self, payload: dict) -> tuple[int, dict]: """Merge stream config; for OOB HTTP ``GET /api/oob/v1/config``.""" @@ -260,7 +267,7 @@ async def http_oob_set_config(self, payload: dict) -> tuple[int, dict]: async def _handle_register(self, ws: Any, client_id: str, payload: dict) -> bool: """Validate and register a headset. Returns True on success.""" - if self._token and payload.get("token") != self._token: + if not self.check_token(payload.get("token")): await self._send_error(ws, "UNAUTHORIZED", "Invalid or missing token") try: await ws.close(1008, "Unauthorized") diff --git a/src/python/isaaccapture/cloudxr/wss.py b/src/python/isaaccapture/cloudxr/wss.py index c76b271c6e..43e7f52d2f 100755 --- a/src/python/isaaccapture/cloudxr/wss.py +++ b/src/python/isaaccapture/cloudxr/wss.py @@ -22,8 +22,10 @@ from .env_config import get_env_config from .oob_teleop_env import ( client_ui_fields_from_env, + control_token_is_generated, default_initial_stream_config, redact_control_token, + resolve_control_token, wss_proxy_port, ) from .oob_teleop_hub import OOB_WS_PATH @@ -666,7 +668,7 @@ async def report_fatal(error: Exception, circumstance: str) -> None: if setup_oob: from .oob_teleop_hub import OOBControlHub # noqa: PLC0415 - control_token = os.environ.get("CONTROL_TOKEN") or None + control_token = resolve_control_token() initial = { **default_initial_stream_config(resolved_port), **client_ui_fields_from_env(), @@ -674,7 +676,7 @@ async def report_fatal(error: Exception, circumstance: str) -> None: hub = OOBControlHub(control_token=control_token, initial_config=initial) log.info( "Teleop control hub enabled (token=%s) OOB_WS=%s initial_stream=%s", - "set" if control_token else "none", + "generated" if control_token_is_generated() else "set", OOB_WS_PATH, initial, ) diff --git a/tests/python/core/cloudxr/oob_hub_test_server.py b/tests/python/core/cloudxr/oob_hub_test_server.py index b397be450d..1c9b481dae 100644 --- a/tests/python/core/cloudxr/oob_hub_test_server.py +++ b/tests/python/core/cloudxr/oob_hub_test_server.py @@ -48,6 +48,8 @@ from oob_teleop_hub import OOBControlHub # noqa: E402 +# controlChannel.test.ts passes the same token. +TEST_CONTROL_TOKEN = "oob-hub-test-token" POLL_INTERVAL_S = 0.05 _WS_HANDSHAKE_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" _OPCODE_TEXT, _OPCODE_CLOSE, _OPCODE_PING, _OPCODE_PONG = 0x1, 0x8, 0x9, 0xA @@ -178,7 +180,7 @@ def _fingerprint(snapshot: dict) -> tuple: async def main() -> None: """Start the hub, announce readiness, then print a snapshot on every state change.""" - hub = OOBControlHub() + hub = OOBControlHub(control_token=TEST_CONTROL_TOKEN) async def handle_tcp( reader: asyncio.StreamReader, writer: asyncio.StreamWriter diff --git a/tests/python/core/cloudxr/test_oob_teleop_env.py b/tests/python/core/cloudxr/test_oob_teleop_env.py index ab799716e2..c9138c214a 100644 --- a/tests/python/core/cloudxr/test_oob_teleop_env.py +++ b/tests/python/core/cloudxr/test_oob_teleop_env.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging +import os from urllib.parse import parse_qs, urlparse import cloudxr_py_test_ns.oob_teleop_env as oob_teleop_env_under_test @@ -612,6 +613,47 @@ def test_startup_banner_hides_control_and_turn_credentials( assert "turnCredential=" in out +def test_resolve_control_token_prefers_configured( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CONTROL_TOKEN", "configured") + assert oob_teleop_env_under_test.resolve_control_token() == "configured" + assert oob_teleop_env_under_test.control_token_is_generated() is False + + +def test_resolve_control_token_generates_stable_random_token( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + first = oob_teleop_env_under_test.resolve_control_token() + assert len(first) >= 32 + assert oob_teleop_env_under_test.resolve_control_token() == first + assert oob_teleop_env_under_test.control_token_is_generated() is True + assert "CONTROL_TOKEN" not in os.environ + + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + assert oob_teleop_env_under_test.resolve_control_token() != first + + +def test_resolve_control_token_treats_empty_env_as_unset( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + monkeypatch.setenv("CONTROL_TOKEN", "") + assert oob_teleop_env_under_test.resolve_control_token() + assert oob_teleop_env_under_test.control_token_is_generated() is True + + +def test_startup_banner_shows_generated_token_once( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + + print_oob_hub_startup_banner(lan_host="127.0.0.1", usb_local=True) + out = capsys.readouterr().out + assert oob_teleop_env_under_test.resolve_control_token() in out + + def test_oob_progress_logs_at_info( monkeypatch: pytest.MonkeyPatch, capsys, caplog ) -> None: diff --git a/tests/python/core/cloudxr/test_oob_teleop_hub.py b/tests/python/core/cloudxr/test_oob_teleop_hub.py index a1c7924df5..173783d568 100644 --- a/tests/python/core/cloudxr/test_oob_teleop_hub.py +++ b/tests/python/core/cloudxr/test_oob_teleop_hub.py @@ -21,6 +21,8 @@ from oob_teleop_hub import OOBControlHub +TOKEN = "test-control-token" + class QueueWS: """Minimal async-iterable WebSocket stand-in for :meth:`OOBControlHub.handle_connection`.""" @@ -56,10 +58,12 @@ def _loads_sent(ws: QueueWS) -> list[dict]: return [json.loads(s) for s in ws.sent] -def test_check_token_no_requirement() -> None: +def test_check_token_without_configured_token_fails_closed() -> None: hub = OOBControlHub(control_token=None) - assert hub.check_token(None) is True - assert hub.check_token("anything") is True + assert hub.check_token(None) is False + assert hub.check_token("") is False + assert hub.check_token("anything") is False + assert OOBControlHub(control_token="").check_token("") is False def test_check_token_required() -> None: @@ -67,11 +71,13 @@ def test_check_token_required() -> None: assert hub.check_token(None) is False assert hub.check_token("wrong") is False assert hub.check_token("secret") is True + assert hub.check_token(123) is False # type: ignore[arg-type] + assert hub.check_token("sécret") is False @pytest.mark.asyncio async def test_get_snapshot_empty() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) snap = await hub.get_snapshot() assert snap["configVersion"] == 0 assert snap["config"] == {} @@ -81,13 +87,18 @@ async def test_get_snapshot_empty() -> None: @pytest.mark.asyncio async def test_headset_register_hello_and_snapshot() -> None: - hub = OOBControlHub(initial_config={"serverIP": "1.2.3.4", "port": 1111}) + hub = OOBControlHub( + control_token=TOKEN, initial_config={"serverIP": "1.2.3.4", "port": 1111} + ) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) await ws.inject( json.dumps( - {"type": "register", "payload": {"role": "headset", "deviceLabel": "Q3"}} + { + "type": "register", + "payload": {"role": "headset", "deviceLabel": "Q3", "token": TOKEN}, + } ) ) await asyncio.sleep(0) @@ -109,10 +120,12 @@ async def test_headset_register_hello_and_snapshot() -> None: @pytest.mark.asyncio async def test_baseline_client_can_register_stream_without_health_probe_reply() -> None: """The pre-healthProbe client still sends its existing OOB messages.""" - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) await ws.inject( json.dumps({"type": "streamStatus", "payload": {"streaming": True}}) @@ -130,10 +143,12 @@ async def test_baseline_client_can_register_stream_without_health_probe_reply() @pytest.mark.asyncio async def test_generation_probe_requires_matching_fresh_client() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() handler = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) registered_at = (await hub.get_snapshot())["headsets"][0]["registeredAt"] / 1000 assert await hub.probe_browser(1, registered_at + 1, timeout=0.02) is None @@ -201,11 +216,15 @@ async def test_register_rejects_bad_token() -> None: @pytest.mark.asyncio async def test_register_rejects_non_headset_role() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "dashboard"}})) + await ws.inject( + json.dumps( + {"type": "register", "payload": {"role": "dashboard", "token": TOKEN}} + ) + ) await asyncio.sleep(0) err = json.loads(ws.sent[0]) assert err["type"] == "error" @@ -217,7 +236,7 @@ async def test_register_rejects_non_headset_role() -> None: @pytest.mark.asyncio async def test_first_message_must_be_register() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) @@ -232,11 +251,13 @@ async def test_first_message_must_be_register() -> None: @pytest.mark.asyncio async def test_client_metrics_stored_in_snapshot() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) await ws.inject( json.dumps( @@ -263,11 +284,13 @@ async def test_client_metrics_stored_in_snapshot() -> None: @pytest.mark.asyncio async def test_stream_status_updates_snapshot() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) # Snapshot before any streamStatus: defaults to streaming=False, since=None. @@ -309,10 +332,12 @@ async def test_stream_status_updates_snapshot() -> None: @pytest.mark.asyncio async def test_structured_stream_status_retains_terminal_until_new_attempt() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) await ws.inject( @@ -375,11 +400,13 @@ async def test_structured_stream_status_retains_terminal_until_new_attempt() -> @pytest.mark.asyncio async def test_wait_for_streaming_returns_on_rising_edge() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) waiter = asyncio.create_task(hub.wait_for_streaming(poll_seconds=0.01)) @@ -400,11 +427,13 @@ async def test_wait_for_streaming_returns_on_rising_edge() -> None: @pytest.mark.asyncio async def test_unknown_message_type_returns_error() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) ws.sent.clear() @@ -419,9 +448,11 @@ async def test_unknown_message_type_returns_error() -> None: @pytest.mark.asyncio async def test_http_oob_set_config_noop_returns_changed_false() -> None: - hub = OOBControlHub(initial_config={"serverIP": "10.0.0.1", "port": 9000}) + hub = OOBControlHub( + control_token=TOKEN, initial_config={"serverIP": "10.0.0.1", "port": 9000} + ) status, body = await hub.http_oob_set_config( - {"config": {"serverIP": "10.0.0.1", "port": 9000}, "token": None} + {"config": {"serverIP": "10.0.0.1", "port": 9000}, "token": TOKEN} ) assert status == 200 assert body.get("changed") is False @@ -429,15 +460,19 @@ async def test_http_oob_set_config_noop_returns_changed_false() -> None: @pytest.mark.asyncio async def test_http_oob_set_config_updates_and_pushes() -> None: - hub = OOBControlHub(initial_config={"serverIP": "127.0.0.1", "port": 49100}) + hub = OOBControlHub( + control_token=TOKEN, initial_config={"serverIP": "127.0.0.1", "port": 49100} + ) hw = QueueWS() th = asyncio.create_task(hub.handle_connection(hw)) - await hw.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await hw.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) hw.sent.clear() status, body = await hub.http_oob_set_config( - {"config": {"serverIP": "10.0.0.2"}, "token": None} + {"config": {"serverIP": "10.0.0.2"}, "token": TOKEN} ) assert status == 200 assert body.get("changed") is True @@ -447,3 +482,55 @@ async def test_http_oob_set_config_updates_and_pushes() -> None: await hw.end_stream() await th + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hub_token", [None, ""]) +async def test_register_rejected_when_hub_has_no_token(hub_token) -> None: + hub = OOBControlHub(control_token=hub_token) + ws = QueueWS() + task = asyncio.create_task(hub.handle_connection(ws)) + + await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await asyncio.sleep(0) + assert json.loads(ws.sent[0])["payload"]["code"] == "UNAUTHORIZED" + assert ws.close_calls + assert (await hub.get_snapshot())["headsets"] == [] + + await ws.end_stream() + await task + + +@pytest.mark.asyncio +async def test_register_rejects_non_string_token() -> None: + hub = OOBControlHub(control_token="123") + ws = QueueWS() + task = asyncio.create_task(hub.handle_connection(ws)) + + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": 123}}) + ) + await asyncio.sleep(0) + assert json.loads(ws.sent[0])["payload"]["code"] == "UNAUTHORIZED" + + await ws.end_stream() + await task + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hub_token", [None, TOKEN]) +@pytest.mark.parametrize("caller_token", [None, "", "wrong"]) +async def test_http_oob_set_config_rejects_unauthenticated( + hub_token, caller_token +) -> None: + hub = OOBControlHub( + control_token=hub_token, initial_config={"serverIP": "10.0.0.1", "port": 9000} + ) + status, body = await hub.http_oob_set_config( + {"config": {"serverIP": "attacker.example"}, "token": caller_token} + ) + assert status == 401 + assert body == {"error": "Unauthorized"} + snapshot = await hub.get_snapshot() + assert snapshot["config"] == {"serverIP": "10.0.0.1", "port": 9000} + assert snapshot["configVersion"] == 0