From 24a00b6acee380b5bf8ea9bf1658152789635228 Mon Sep 17 00:00:00 2001 From: Andre van Zuydam Date: Fri, 23 Oct 2026 16:49:55 +0200 Subject: [PATCH] fix(rtc): require auth on config + signalling by default (ADR-0101, #252) Co-Authored-By: Claude Opus 4.8 Signed-off-by: Andre van Zuydam --- tests/test_realtime_chat.py | 5 +- tests/test_rtc_require_auth_contract.py | 86 +++++++++++++++++++++++++ tina4_python/realtime/__init__.py | 11 ++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/test_rtc_require_auth_contract.py diff --git a/tests/test_realtime_chat.py b/tests/test_realtime_chat.py index de548672..9fc852f0 100644 --- a/tests/test_realtime_chat.py +++ b/tests/test_realtime_chat.py @@ -247,7 +247,10 @@ def test_chat_feature_registers_paths(self, chat): def test_config_advertises_chat(self, chat): realtime(prefix="/t-cfg", features=["calls", "chat"]) - res = TestClient().get("/t-cfg/api/rtc/config") + # ADR-0101: the config route is secured by default, so authenticate. + token = get_token({"user_id": 1}) + res = TestClient().get("/t-cfg/api/rtc/config", + headers={"Authorization": f"Bearer {token}"}) body = res.json() assert body["chat"] == "/t-cfg/ws/chat/{channel}" assert body["messages"] == "/t-cfg/api/channels/{id}/messages" diff --git a/tests/test_rtc_require_auth_contract.py b/tests/test_rtc_require_auth_contract.py new file mode 100644 index 00000000..7788d94c --- /dev/null +++ b/tests/test_rtc_require_auth_contract.py @@ -0,0 +1,86 @@ +# Copyright (c) 2026 Code Infinity +# SPDX-License-Identifier: MPL-2.0 +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +"""RTC config + signalling require auth by default (ADR-0101, tina4-python#252). + +The `/api/rtc/config` route mints a fresh TURN credential on every call, and the +`/ws/rtc/{room}` signalling socket relays a call. Left public, anyone can use the +operator's TURN server as a relay - unbounded bandwidth on their bill - and a +short credential TTL does not bound it (a script just fetches a new one). So both +are secured by default; an app that genuinely wants anonymous calling opts out +with TINA4_RTC_REQUIRE_AUTH=false. + +Answer key: tina4-documentation/plan/v3/fixtures/rtc_auth_contract.json. The same +cases run in all four frameworks on the REAL router tables after a real +realtime() registration - no mocks; auth_required is read straight off the +registered route. +""" +import pytest + +from tina4_python.core import router +from tina4_python.core.router import Router +from tina4_python.realtime import realtime + +CONFIG = "/api/rtc/config" +SIGNAL = "/ws/rtc" + + +def _http_auth(path_contains: str) -> bool: + routes = [r for r in router._routes if path_contains in r["path"]] + assert routes, f"no HTTP route registered containing {path_contains}" + return bool(routes[-1]["auth_required"]) + + +def _ws_auth(path_contains: str) -> bool: + routes = [r for r in Router.get_web_socket_routes() if path_contains in r["path"]] + assert routes, f"no WS route registered containing {path_contains}" + return bool(routes[-1]["auth_required"]) + + +@pytest.fixture(autouse=True) +def _clean_routes(): + Router.clear() + yield + Router.clear() + + +def _register(monkeypatch, value): + if value is None: + monkeypatch.delenv("TINA4_RTC_REQUIRE_AUTH", raising=False) + else: + monkeypatch.setenv("TINA4_RTC_REQUIRE_AUTH", value) + realtime(prefix="/rtc-t", features=["calls", "chat"]) + + +def test_config_route_requires_auth_by_default(monkeypatch): + _register(monkeypatch, None) + assert _http_auth(CONFIG) is True + + +def test_signalling_socket_requires_auth_by_default(monkeypatch): + _register(monkeypatch, None) + assert _ws_auth(SIGNAL) is True + + +def test_require_auth_false_opens_the_config_route(monkeypatch): + _register(monkeypatch, "false") + assert _http_auth(CONFIG) is False + + +def test_require_auth_false_opens_the_signalling_socket(monkeypatch): + _register(monkeypatch, "false") + assert _ws_auth(SIGNAL) is False + + +def test_require_auth_explicit_true_secures_both(monkeypatch): + _register(monkeypatch, "true") + assert _http_auth(CONFIG) is True and _ws_auth(SIGNAL) is True + + +def test_only_explicit_falsey_opens_the_route(monkeypatch): + # An unrecognised value is NOT an opt-out - secure-by-default holds. + _register(monkeypatch, "yes-please") + assert _http_auth(CONFIG) is True diff --git a/tina4_python/realtime/__init__.py b/tina4_python/realtime/__init__.py index cbbc9e05..34884087 100644 --- a/tina4_python/realtime/__init__.py +++ b/tina4_python/realtime/__init__.py @@ -228,6 +228,13 @@ async def _authorized(identity, channel_id) -> bool: paths.setdefault("config", f"{p}/api/rtc/config") paths["files"] = f"{p}/api/files" + # ADR-0101: the config route mints TURN credentials and the signalling + # socket relays calls, so both require a valid token UNLESS the app opts out + # with TINA4_RTC_REQUIRE_AUTH=false (anonymous calling). Secure-by-default: + # only an explicit false/0/no/off opens them. + require_auth = (os.getenv("TINA4_RTC_REQUIRE_AUTH") or "true").strip().lower() \ + not in ("false", "0", "no", "off") + # ── Self-describing bootstrap: the client fetches this instead of # hardcoding any URL, so moving `prefix` moves the client too. ── if "config" in paths: @@ -243,6 +250,8 @@ async def rtc_config(request, response): body["files"] = paths["files"] return response(body) + if require_auth: # ADR-0101 + rtc_config._secured = True Router.get(paths["config"], rtc_config) # ── calls: WebRTC signalling relay (mesh) ────────────────────────── @@ -259,6 +268,8 @@ async def rtc_signalling(connection, event, data): # room. Tina4 never parses the SDP; peers filter by `to`. await connection.broadcast_to_room(key, data, exclude_self=True) + if require_auth: # ADR-0101 + rtc_signalling._secured = True Router.websocket(f"{paths['signalling']}/{{room}}", rtc_signalling) # ── chat: persistent channels + presence/typing/receipts ───────────