Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion tests/test_realtime_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
86 changes: 86 additions & 0 deletions tests/test_rtc_require_auth_contract.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions tina4_python/realtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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) ──────────────────────────
Expand All @@ -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 ───────────
Expand Down
Loading