diff --git a/companion/README.md b/companion/README.md index 1d389b1..8f22ff2 100644 --- a/companion/README.md +++ b/companion/README.md @@ -1,20 +1,28 @@ -# Companion bridge (read-only) +# Companion bridge -A thin, additive layer so a LAN client can discover what an Odysseus server -offers, without duplicating any LLM logic. Reachable with either a logged-in -cookie session or a Bearer `ody_` API token (auth is enforced globally by -`AuthMiddleware`). +A thin, additive layer so a LAN client (e.g. a phone) can discover what an +Odysseus server offers and pair to it, without duplicating any LLM logic. -| Method | Path | Purpose | -|---|---|---| -| GET | `/api/companion/ping` | cheap, auth-validated health check | -| GET | `/api/companion/info` | server identity + capability flags | -| GET | `/api/companion/models` | the **caller's own** model endpoints | +| Method | Path | Auth | Purpose | +|---|---|---|---| +| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check | +| GET | `/api/companion/info` | session or token | server identity + capability flags | +| GET | `/api/companion/models` | session or token | the **caller's own** model endpoints | +| GET | `/api/companion/pair` | **admin cookie** | pairing page (a form; never mints) | +| POST | `/api/companion/pair` | **admin cookie** | mint a one-time pairing token (`?format=json` for an in-app screen) | -`/models` scopes to the caller's real owner (the token's owner for bearer -callers) plus legacy null-owner shared rows, the same rule as `owner_filter`. It -never returns API-key material. The owner rule lives in two pure, tested helpers -(`token_owner`, `owner_can_see`) — see `tests/test_companion_readonly.py`. +`/models` scopes to the caller's real owner plus legacy null-owner shared rows +(same rule as `owner_filter`) and never returns API-key material. -This module is intentionally read-only. Pairing/token-minting, token-owner -session attribution, and any mutation endpoints are proposed in separate PRs. +## Pairing CSRF posture + +Minting happens **only on POST**. The session cookie is `SameSite=Lax` +(`routes/auth_routes.py`), so a browser will not send it on a cross-site POST — +the same protection `POST /api/tokens` relies on. A `GET` would be unsafe (Lax +cookies ride top-level GET navigations), so `GET /pair` only renders a form. +Minting invalidates the auth middleware's token cache, so a freshly minted token +works on the next request without a restart. + +The pairing/scoping rules live in small, tested units (`token_owner`, +`owner_can_see`, `mint_pairing_token`, `pairing.*`) — see +`tests/test_companion_readonly.py` and `tests/test_companion_pairing.py`. diff --git a/companion/__init__.py b/companion/__init__.py index 2a39e84..58a841a 100644 --- a/companion/__init__.py +++ b/companion/__init__.py @@ -1,8 +1,9 @@ -"""Odysseus companion bridge — additive, read-only LAN endpoints. +"""Odysseus companion bridge — additive LAN endpoints. -Exposes /api/companion/ping, /info, and an owner-scoped /models so a LAN client -can discover what a server offers. No new LLM logic; auth is enforced by the -existing AuthMiddleware. See companion/README.md. +Read endpoints (/api/companion/ping, /info, owner-scoped /models) so a LAN +client can discover what a server offers, plus admin-only pairing +(/api/companion/pair) that mints a one-time chat-scoped token on POST. No new LLM +logic; auth is enforced by the existing AuthMiddleware. See companion/README.md. """ from companion.routes import setup_companion_routes diff --git a/companion/pairing.py b/companion/pairing.py new file mode 100644 index 0000000..66c63bc --- /dev/null +++ b/companion/pairing.py @@ -0,0 +1,121 @@ +"""Shared pairing helpers for the companion bridge. + +Token minting + LAN discovery + QR rendering, kept here as small, importable +units so the route layer stays thin and the logic is directly testable. +""" + +from __future__ import annotations + +import json +import os +import secrets +import socket +import uuid + +import bcrypt + +PAIRING_VERSION = 1 +COMPANION_SCOPE = "chat" + + +def default_port() -> int: + """Best guess at the port the server is reachable on. Callers that know the + real request port should pass it explicitly.""" + try: + return int(os.environ.get("APP_PORT", "7000")) + except ValueError: + return 7000 + + +def lan_ip_candidates() -> list[str]: + """Likely LAN IPv4 addresses for this host, best candidate first. + + The UDP-connect trick reveals the egress interface the OS would use to reach + the default gateway -- i.e. the address a phone on the same Wi-Fi should + target. No packets are actually sent. Loopback is dropped. + """ + candidates: list[str] = [] + + def _add(ip): + if ip and ip not in candidates and not ip.startswith("127."): + candidates.append(ip) + + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + _add(s.getsockname()[0]) + except OSError: + pass + finally: + s.close() + + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + _add(info[4][0]) + except OSError: + pass + + return candidates + + +def find_admin_user() -> str | None: + """Resolve an admin username from data/auth.json (schema uses is_admin), + falling back to the first user.""" + auth_path = os.path.join("data", "auth.json") + try: + with open(auth_path, "r", encoding="utf-8") as f: + users = (json.load(f) or {}).get("users", {}) + except (OSError, json.JSONDecodeError): + return None + for uname, udata in users.items(): + if udata.get("is_admin") is True: + return uname + return next(iter(users), None) + + +def mint_token(owner: str, name: str = "companion") -> tuple[str, str]: + """Create a chat-scoped API token row and return (token_id, raw_token). + + The raw token is returned ONCE -- only its bcrypt hash + an 8-char prefix + are persisted. Mirrors routes/api_token_routes.py so cookie- and + companion-minted tokens are indistinguishable to the auth middleware. + """ + from core.database import get_db_session, ApiToken + + raw_token = "ody_" + secrets.token_urlsafe(32) + token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode() + token_id = str(uuid.uuid4())[:8] + + with get_db_session() as db: + db.add(ApiToken( + id=token_id, + owner=owner, + name=name, + token_hash=token_hash, + token_prefix=raw_token[:8], + scopes=COMPANION_SCOPE, + is_active=True, + )) + return token_id, raw_token + + +def pairing_payload(host: str, port: int, token: str) -> dict: + """The exact JSON a client scans / accepts. Keep keys stable.""" + return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token} + + +def pairing_qr_png_data_uri(payload: dict) -> str | None: + """Render the pairing payload as a QR `data:` URI for an . Returns None + if the optional qrcode dep is unavailable.""" + try: + import base64 + import io + + import qrcode + + img = qrcode.make(json.dumps(payload, separators=(",", ":"))) + buf = io.BytesIO() + img.save(buf, format="PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + except Exception: + return None diff --git a/companion/routes.py b/companion/routes.py index 34427d0..e0a24a1 100644 --- a/companion/routes.py +++ b/companion/routes.py @@ -1,19 +1,30 @@ -"""Companion bridge — read-only endpoints (/api/companion/*). +"""Companion bridge — /api/companion/*. A thin, additive layer so a LAN client (e.g. a phone) can discover what a server -offers without duplicating any LLM logic. This module is intentionally -read-only: it exposes a cheap health check, server identity, and the caller's -own model list. Pairing/token-minting and any mutation live in separate changes. +offers and pair to it, without duplicating any LLM logic. Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here means the caller is authenticated by either a cookie session or a Bearer `ody_` -API token. +API token. The read endpoints (ping/info/models) accept either; the pairing +endpoints are admin-cookie only. + +Pairing CSRF posture: minting happens ONLY on POST. The session cookie is +SameSite=Lax (routes/auth_routes.py), which a browser does not send on a +cross-site POST, so an admin's cookie can't be used by a malicious page to mint +a token -- the same protection the existing POST /api/tokens relies on. Minting +on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET +/pair only renders a form. """ +import html + from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse from src.auth_helpers import get_current_user +from companion import pairing as _pairing + def token_owner(request: Request) -> str | None: """The real owner to attribute a request to, for read-scoping. @@ -40,6 +51,20 @@ def owner_can_see(row_owner, owner) -> bool: return row_owner is None or row_owner == owner +def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]: + """Mint a pairing token AND invalidate the auth middleware's in-memory token + cache, so the new token is accepted on the very next request without a server + restart. Returns (token_id, raw_token); the raw token is shown once. + + `invalidate` is the app's request.app.state.invalidate_token_cache callable + (passed in so this stays a pure, testable unit). + """ + token_id, raw_token = _pairing.mint_token(owner) + if callable(invalidate): + invalidate() + return token_id, raw_token + + def setup_companion_routes() -> APIRouter: router = APIRouter(prefix="/api/companion", tags=["companion"]) @@ -93,8 +118,6 @@ def setup_companion_routes() -> APIRouter: if owner: q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711 for ep in q.all(): - # Defence in depth: never emit a row the owner rule rejects, even - # if the SQL filter above were ever loosened. if not owner_can_see(ep.owner, owner): continue try: @@ -121,4 +144,92 @@ def setup_companion_routes() -> APIRouter: db.close() return {"endpoints": out} + @router.get("/pair") + def pair_page(request: Request): + """Admin-only pairing page. Renders a form that POSTs to mint a code. + + A GET never mints a credential: SameSite=Lax session cookies ride + top-level GET navigations, so minting on GET would be triggerable by a + link or (CSRF). The actual mint is the POST handler below. + """ + require_admin(request) + page = """ + +Pair a device + +
+

Pair a device

+

Generate a one-time pairing code (a chat-scoped API token) for a LAN client.

+
+ +
+

Admin only. Each code mints a new token, shown once. Manage or revoke under Settings → API tokens.

+
""" + return HTMLResponse(page) + + @router.post("/pair") + def pair_create(request: Request): + """Mint a pairing code. Admin-cookie only; CSRF-safe because the + SameSite=Lax session cookie is not sent on a cross-site POST (same + protection as POST /api/tokens). Minting invalidates the token cache so + the code works immediately, no restart. `?format=json` returns the + payload for an in-app pairing screen.""" + require_admin(request) + owner = get_current_user(request) + invalidate = getattr(request.app.state, "invalidate_token_cache", None) + token_id, raw_token = mint_pairing_token(owner, invalidate) + + hosts = _pairing.lan_ip_candidates() + host = hosts[0] if hosts else "127.0.0.1" + port = request.url.port or _pairing.default_port() + payload = _pairing.pairing_payload(host, port, raw_token) + qr = _pairing.pairing_qr_png_data_uri(payload) + qr_ok = bool(qr and qr.startswith("data:image/png;base64,")) + + if (request.query_params.get("format") or "").lower() == "json": + return { + "host": host, + "port": port, + "token": raw_token, + "token_id": token_id, + "hosts": hosts, + "payload": payload, + "qr": qr if qr_ok else None, + } + + import json as _json + payload_json = _json.dumps(payload, separators=(",", ":")) + # Only ever emit a known PNG data-URI into the src; every other value is + # html.escaped. + qr_block = ( + f'Pairing QR' + if qr_ok else "

QR rendering unavailable -- enter the details manually.

" + ) + page = f""" + +Pairing code + +
+

Pairing code

+ {qr_block} +
Host: {html.escape(host)}
+
Port: {html.escape(str(port))}
+
Token: {html.escape(raw_token)}
+
Payload: {html.escape(payload_json)}
+

Shown once. This grants chat access to your Odysseus; revoke it + in Settings → API tokens (id {html.escape(token_id)}). The + device must be on the same network, and the server must bind to your LAN.

+
""" + return HTMLResponse(page) + return router diff --git a/tests/test_companion_pairing.py b/tests/test_companion_pairing.py new file mode 100644 index 0000000..781e540 --- /dev/null +++ b/tests/test_companion_pairing.py @@ -0,0 +1,156 @@ +"""Tests for the companion pairing endpoints (split 3/4). + +Covers what the review asked for: + - a non-admin / bearer caller cannot call /api/companion/pair (admin-only) + - the pairing token is minted once (hashed at rest) and the mint invalidates + the auth cache so it works immediately, no restart + - minting is a POST, never a GET (CSRF: a SameSite=Lax cookie rides a + top-level GET, so GET-minting would be triggerable by a link / ) +""" + +import contextlib +import os +import sys +import types +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Capture what mint_token would persist, via a stubbed core.database. +_CAPTURED = {} + + +class _ApiToken: + def __init__(self, **kw): + _CAPTURED.clear() + _CAPTURED.update(kw) + self.__dict__.update(kw) + + +@contextlib.contextmanager +def _get_db_session(): + yield MagicMock() + + +# core/__init__ pulls in models/session_manager which import many ORM names from +# core.database; under conftest's sqlalchemy stubs the real module can't load. +# A __getattr__ module resolves ANY requested name to a MagicMock, while keeping +# our real get_db_session/ApiToken for the mint test. +class _DBStub(types.ModuleType): + def __getattr__(self, name): # noqa: D401 + return MagicMock() + + +_db = _DBStub("core.database") +_db.get_db_session = _get_db_session +_db.ApiToken = _ApiToken +sys.modules["core.database"] = _db # overwrite any minimal stub from a sibling test + +for _name, _attrs in { + "core.auth": {"AuthManager": MagicMock()}, + "src.endpoint_resolver": {"build_chat_url": (lambda u: u)}, +}.items(): + if _name not in sys.modules: + _mm = types.ModuleType(_name) + for _k, _v in _attrs.items(): + setattr(_mm, _k, _v) + sys.modules[_name] = _mm + +from fastapi import HTTPException # noqa: E402 + +import companion.pairing as P # noqa: E402 +from companion.routes import mint_pairing_token, setup_companion_routes # noqa: E402 +from core.middleware import require_admin # noqa: E402 + + +# --- token minting: shown once, hashed at rest ----------------------------- + +def test_mint_token_returns_raw_once_and_stores_only_a_hash(): + token_id, raw = P.mint_token("alice") + assert raw.startswith("ody_") + # The persisted row stores a bcrypt hash + prefix, never the plaintext. + assert _CAPTURED["token_hash"] != raw + assert _CAPTURED["token_hash"].startswith("$2") # bcrypt + assert _CAPTURED["token_prefix"] == raw[:8] + assert _CAPTURED["owner"] == "alice" + assert _CAPTURED["scopes"] == "chat" + assert _CAPTURED["is_active"] is True + + +def test_mint_pairing_token_invalidates_cache(monkeypatch): + # The mint must flip the auth middleware's cache so the token works on the + # very next request, with no restart. + monkeypatch.setattr(P, "mint_token", lambda owner, name="companion": ("id1", "ody_demo")) + invalidate = MagicMock() + token_id, raw = mint_pairing_token("alice", invalidate) + assert (token_id, raw) == ("id1", "ody_demo") + invalidate.assert_called_once() + + +def test_mint_pairing_token_tolerates_no_invalidator(monkeypatch): + monkeypatch.setattr(P, "mint_token", lambda owner, name="companion": ("id1", "ody_demo")) + # Must not blow up if the app didn't expose an invalidator. + assert mint_pairing_token("alice", None) == ("id1", "ody_demo") + + +def test_pairing_payload_shape(): + p = P.pairing_payload("192.168.1.9", 7000, "ody_x") + assert p == {"v": 1, "host": "192.168.1.9", "port": 7000, "token": "ody_x"} + + +# --- admin-only gate: a bearer/non-admin caller is rejected ---------------- + +def _admin_mgr(is_admin): + return SimpleNamespace(is_admin=lambda u: is_admin, is_configured=True) + + +def _req(current_user, *, api_token=False, is_admin=False): + return SimpleNamespace( + state=SimpleNamespace(current_user=current_user, api_token=api_token), + headers={}, + app=SimpleNamespace(state=SimpleNamespace(auth_manager=_admin_mgr(is_admin))), + ) + + +def test_bearer_token_caller_cannot_pair(monkeypatch): + # Bearer callers come through as the "api" pseudo-user, which is not admin. + monkeypatch.setenv("AUTH_ENABLED", "true") + with pytest.raises(HTTPException) as exc: + require_admin(_req("api", api_token=True, is_admin=False)) + assert exc.value.status_code == 403 + + +def test_non_admin_user_cannot_pair(monkeypatch): + monkeypatch.setenv("AUTH_ENABLED", "true") + with pytest.raises(HTTPException) as exc: + require_admin(_req("bob", is_admin=False)) + assert exc.value.status_code == 403 + + +def test_admin_user_passes_the_gate(monkeypatch): + monkeypatch.setenv("AUTH_ENABLED", "true") + # Should not raise. + require_admin(_req("alice", is_admin=True)) + + +# --- CSRF: minting is POST, never GET -------------------------------------- + +def _pair_methods(): + router = setup_companion_routes() + methods = set() + for r in router.routes: + path = getattr(r, "path", "") + if path.endswith("/pair"): + methods |= set(getattr(r, "methods", set()) or set()) + return methods + + +def test_pair_is_minted_via_post_not_get(): + methods = _pair_methods() + assert "POST" in methods, "pairing must accept POST (the mint)" + assert "GET" in methods, "GET should render the form page" + # The distinction is enforced in the handlers: GET renders a form and never + # mints; only POST calls mint_pairing_token.